diff --git a/include/GithubRootCA.h b/include/GithubRootCA.h new file mode 100644 index 0000000..dd416a4 --- /dev/null +++ b/include/GithubRootCA.h @@ -0,0 +1,72 @@ +#pragma once + +namespace SQM +{ + + // Root CAs covering api.github.com (chains through Sectigo to + // "USERTrust ECC Certification Authority") and the release-asset + // redirect target objects.githubusercontent.com / *.github.io CDN + // (chains through Let's Encrypt to "ISRG Root X1"). + // + // Extracted from the local system trust store and verified against the + // live TLS chains for both domains (openssl verify) rather than + // hand-typed, since a single wrong byte in a pinned cert silently + // breaks or weakens validation. + // + // These are long-lived roots (2035/2038 expiry) but GitHub's + // intermediate chain can still change: if OTA checks start failing + // with TLS handshake errors before firmware can be updated, that's the + // first thing to re-verify (`openssl s_client -connect + // api.github.com:443 -showcerts`). + static const char *GITHUB_ROOT_CA_PEM = + // USERTrust ECC Certification Authority (root for api.github.com) + "-----BEGIN CERTIFICATE-----\n" + "MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDEL\n" + "MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl\n" + "eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT\n" + "JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMjAx\n" + "MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNVBAgT\n" + "Ck5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVUaGUg\n" + "VVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlm\n" + "aWNhdGlvbiBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqflo\n" + "I+d61SRvU8Za2EurxtW20eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinng\n" + "o4N+LZfQYcTxmdwlkWOrfzCjtHDix6EznPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0G\n" + "A1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNVHQ8BAf8EBAMCAQYwDwYD\n" + "VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBBHU6+4WMB\n" + "zzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbW\n" + "RNZu9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg=\n" + "-----END CERTIFICATE-----\n" + // ISRG Root X1 (root for the objects.githubusercontent.com / GitHub Pages CDN) + "-----BEGIN CERTIFICATE-----\n" + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" + "-----END CERTIFICATE-----\n"; + +} // namespace SQM diff --git a/include/OtaUpdater.h b/include/OtaUpdater.h new file mode 100644 index 0000000..3faed1f --- /dev/null +++ b/include/OtaUpdater.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +namespace SQM +{ + + struct GithubRelease + { + std::string tag; // e.g. "v0.0.3" + std::string name; // release title + bool prerelease = false; // GitHub's native beta/stable flag + std::string publishedAt; + std::string firmwareAssetUrl; // browser_download_url for sqmeter-firmware-.bin + size_t firmwareAssetSize = 0; + std::string fsAssetUrl; // browser_download_url for sqmeter-littlefs-.bin + size_t fsAssetSize = 0; + }; + + // Parses a GitHub "list releases" API JSON body and returns entries that + // have BOTH a sqmeter-firmware-*.bin and a sqmeter-littlefs-*.bin asset, + // filtered by track ("stable" -> prerelease == false, "beta" -> + // prerelease == true). A release missing either asset is skipped + // entirely - firmware and web UI must always be flashed as a matched + // pair to avoid frontend/backend drift. + // Exposed standalone (no networking) so it's unit-testable natively. + std::vector parseGithubReleases(const std::string &json, const std::string &track); + + class OtaUpdater + { + public: + enum class Phase + { + Idle, + Checking, + Downloading, + Writing, + Done, + Error + }; + + using ProgressCallback = std::function; + using ErrorCallback = std::function; + using RestartCallback = std::function; + + OtaUpdater(ProgressCallback onProgress, ErrorCallback onError, RestartCallback onRestart); + + // Blocking HTTPS GET against the GitHub releases API. Safe to call + // from a request handler - completes in a couple seconds. + std::vector checkForUpdate(const std::string &track, std::string &error); + + // Starts the download+flash of BOTH the firmware and filesystem + // assets on its own FreeRTOS task so the caller (an ESPAsyncWebServer + // request handler) returns immediately instead of blocking the web + // server for the duration of the download. Only reboots after both + // have been written successfully, so the device never runs a + // mismatched firmware/web-UI pair. + // Returns false if an update is already in progress. + bool applyUpdate(const GithubRelease &release); + + Phase phase() const { return currentPhase; } + + private: + void runApply(GithubRelease release); + bool downloadAndFlashFirmware(const std::string &url, size_t expectedSize, int progressFrom, int progressTo); + bool downloadAndFlashFilesystem(const std::string &url, size_t expectedSize, int progressFrom, int progressTo); + + ProgressCallback progressCb; + ErrorCallback errorCb; + RestartCallback restartCb; + volatile Phase currentPhase = Phase::Idle; + }; + +} // namespace SQM diff --git a/include/WebServer.h b/include/WebServer.h index 939f2bd..8310e1f 100644 --- a/include/WebServer.h +++ b/include/WebServer.h @@ -9,6 +9,7 @@ #include "calculations/SkyQuality.h" #include "TimeManager.h" #include "MQTTClient.h" +#include "OtaUpdater.h" #include #include #include @@ -112,11 +113,14 @@ namespace SQM std::string pendingWifiSSID; std::string pendingWifiPassword; + std::unique_ptr otaUpdater; + // Setup route handlers void setupStaticRoutes(); void setupAPIRoutes(); void setupWebSocket(); void setupOTA(); + void setupGithubUpdates(); // API endpoint handlers void handleGetStatus(AsyncWebServerRequest *request); diff --git a/src/OtaUpdater.cpp b/src/OtaUpdater.cpp new file mode 100644 index 0000000..b9da11f --- /dev/null +++ b/src/OtaUpdater.cpp @@ -0,0 +1,381 @@ +#include "OtaUpdater.h" +#include "GithubRootCA.h" +#include "Logger.h" +#include "version.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace SQM +{ + namespace + { + constexpr const char *TAG = "OtaUpdater"; + constexpr const char *RELEASES_URL = "https://api.github.com/repos/DeanJ87/SQMeter/releases"; + constexpr size_t JSON_DOC_CAPACITY = 24576; + constexpr uint32_t HTTP_TIMEOUT_MS = 15000; + constexpr size_t OTA_TASK_STACK_WORDS = 8192; + + bool findAsset(JsonArrayConst assets, const char *prefix, std::string &url, size_t &size) + { + const size_t prefixLen = strlen(prefix); + for (JsonObjectConst asset : assets) + { + const char *name = asset["name"] | ""; + if (strncmp(name, prefix, prefixLen) == 0 && strstr(name, ".bin") != nullptr) + { + url = asset["browser_download_url"] | ""; + size = asset["size"] | 0; + return !url.empty(); + } + } + return false; + } + + // Streams an HTTPS GET body to `onChunk`, reporting progress scaled + // into [progressFrom, progressTo]. Shared by the firmware and + // filesystem downloads so both go through identical retry/EOF logic. + bool streamDownload(const std::string &url, size_t sizeHint, int progressFrom, int progressTo, + const std::function &onChunk, + const OtaUpdater::ProgressCallback &progressCb, + size_t &written, std::string &error) + { + WiFiClientSecure client; + client.setCACert(GITHUB_ROOT_CA_PEM); + + HTTPClient http; + http.setTimeout(HTTP_TIMEOUT_MS); + // GitHub release assets are served via a redirect to a CDN URL; follow it. + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + if (!http.begin(client, url.c_str())) + { + error = "Failed to initialize download"; + return false; + } + http.addHeader("User-Agent", "SQMeter-ESP32"); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) + { + error = "Download failed (HTTP " + std::to_string(httpCode) + ")"; + http.end(); + return false; + } + + int contentLength = http.getSize(); + size_t expectedSize = contentLength > 0 ? static_cast(contentLength) : sizeHint; + + WiFiClient *stream = http.getStreamPtr(); + uint8_t buf[1024]; + written = 0; + int lastPercent = -1; + + while (http.connected() && (written < expectedSize || expectedSize == 0)) + { + size_t available = stream->available(); + if (!available) + { + if (!http.connected()) + break; + delay(10); + continue; + } + + size_t toRead = std::min(available, sizeof(buf)); + size_t readBytes = stream->readBytes(buf, toRead); + if (readBytes == 0) + break; + + if (!onChunk(buf, readBytes)) + { + error = "Flash write failed"; + http.end(); + return false; + } + + written += readBytes; + if (expectedSize > 0 && progressCb) + { + int percent = progressFrom + static_cast( + (written * static_cast(progressTo - progressFrom)) / expectedSize); + if (percent != lastPercent) + { + lastPercent = percent; + progressCb(percent); + } + } + } + http.end(); + + if (expectedSize > 0 && written != expectedSize) + { + error = "Download incomplete (" + std::to_string(written) + " of " + std::to_string(expectedSize) + " bytes)"; + return false; + } + + return true; + } + } + + std::vector parseGithubReleases(const std::string &json, const std::string &track) + { + std::vector results; + + DynamicJsonDocument doc(JSON_DOC_CAPACITY); + DeserializationError err = deserializeJson(doc, json); + if (err) + { + Logger::error(TAG, "Failed to parse releases JSON: %s", err.c_str()); + return results; + } + + const bool wantPrerelease = (track == "beta"); + + for (JsonObjectConst release : doc.as()) + { + const bool prerelease = release["prerelease"] | false; + if (prerelease != wantPrerelease) + continue; + + GithubRelease entry; + entry.tag = std::string(release["tag_name"] | ""); + entry.name = std::string(release["name"] | entry.tag.c_str()); + entry.prerelease = prerelease; + entry.publishedAt = std::string(release["published_at"] | ""); + + if (entry.tag.empty()) + continue; + + JsonArrayConst assets = release["assets"].as(); + const bool hasFirmware = findAsset(assets, "sqmeter-firmware-", entry.firmwareAssetUrl, entry.firmwareAssetSize); + const bool hasFs = findAsset(assets, "sqmeter-littlefs-", entry.fsAssetUrl, entry.fsAssetSize); + + // Firmware and web UI must always ship as a matched pair - a + // release missing either asset (e.g. still building) is not + // offered as an update target, to prevent frontend/backend drift. + if (!hasFirmware || !hasFs) + continue; + + results.push_back(std::move(entry)); + } + + return results; + } + + OtaUpdater::OtaUpdater(ProgressCallback onProgress, ErrorCallback onError, RestartCallback onRestart) + : progressCb(std::move(onProgress)), errorCb(std::move(onError)), restartCb(std::move(onRestart)) + { + } + + std::vector OtaUpdater::checkForUpdate(const std::string &track, std::string &error) + { + currentPhase = Phase::Checking; + + WiFiClientSecure client; + client.setCACert(GITHUB_ROOT_CA_PEM); + + HTTPClient http; + http.setTimeout(HTTP_TIMEOUT_MS); + if (!http.begin(client, RELEASES_URL)) + { + error = "Failed to initialize HTTPS client"; + currentPhase = Phase::Error; + return {}; + } + http.addHeader("User-Agent", "SQMeter-ESP32"); + http.addHeader("Accept", "application/vnd.github+json"); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) + { + error = "GitHub API request failed (HTTP " + std::to_string(httpCode) + ")"; + Logger::error(TAG, "%s", error.c_str()); + http.end(); + currentPhase = Phase::Error; + return {}; + } + + std::string body = http.getString().c_str(); + http.end(); + + currentPhase = Phase::Idle; + return parseGithubReleases(body, track); + } + + bool OtaUpdater::applyUpdate(const GithubRelease &release) + { + if (currentPhase == Phase::Downloading || currentPhase == Phase::Writing) + { + return false; + } + + currentPhase = Phase::Downloading; + + struct TaskArgs + { + OtaUpdater *self; + GithubRelease release; + }; + auto *args = new TaskArgs{this, release}; + + xTaskCreatePinnedToCore( + [](void *arg) + { + auto *a = static_cast(arg); + a->self->runApply(a->release); + delete a; + vTaskDelete(nullptr); + }, + "ota_gh_apply", + OTA_TASK_STACK_WORDS, + args, + 1, + nullptr, + 1 // same core AsyncTCP runs on is fine - this task blocks on network I/O, not CPU + ); + + return true; + } + + bool OtaUpdater::downloadAndFlashFirmware(const std::string &url, size_t expectedSize, int progressFrom, int progressTo) + { + if (!Update.begin(expectedSize > 0 ? expectedSize : UPDATE_SIZE_UNKNOWN)) + { + Logger::error(TAG, "Update.begin failed: %d", Update.getError()); + if (errorCb) + errorCb("Not enough space for firmware update"); + return false; + } + + currentPhase = Phase::Writing; + + size_t written = 0; + std::string error; + bool ok = streamDownload( + url, expectedSize, progressFrom, progressTo, + [](const uint8_t *data, size_t len) + { return Update.write(const_cast(data), len) == len; }, + progressCb, written, error); + + if (!ok) + { + Logger::error(TAG, "Firmware download/write failed: %s", error.c_str()); + if (errorCb) + errorCb(error.c_str()); + Update.abort(); + return false; + } + + if (!Update.end(true)) + { + Logger::error(TAG, "Update.end failed: %d", Update.getError()); + if (errorCb) + errorCb("Firmware update finalization failed"); + return false; + } + + Logger::info(TAG, "Firmware flashed successfully (%u bytes)", static_cast(written)); + return true; + } + + bool OtaUpdater::downloadAndFlashFilesystem(const std::string &url, size_t expectedSize, int progressFrom, int progressTo) + { + const esp_partition_t *fsPartition = esp_partition_find_first( + ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, NULL); + + if (!fsPartition) + { + Logger::error(TAG, "Filesystem partition not found"); + if (errorCb) + errorCb("Filesystem partition not found"); + return false; + } + + if (expectedSize > fsPartition->size) + { + Logger::error(TAG, "Filesystem image too large (%u > %u)", + static_cast(expectedSize), static_cast(fsPartition->size)); + if (errorCb) + errorCb("Filesystem image too large for partition"); + return false; + } + + LittleFS.end(); + + Logger::info(TAG, "Erasing filesystem partition..."); + if (esp_partition_erase_range(fsPartition, 0, fsPartition->size) != ESP_OK) + { + Logger::error(TAG, "Filesystem partition erase failed"); + if (errorCb) + errorCb("Failed to erase filesystem partition"); + return false; + } + + currentPhase = Phase::Writing; + + size_t writeOffset = 0; + size_t written = 0; + std::string error; + bool ok = streamDownload( + url, expectedSize, progressFrom, progressTo, + [fsPartition, &writeOffset](const uint8_t *data, size_t len) + { + if (esp_partition_write(fsPartition, writeOffset, data, len) != ESP_OK) + return false; + writeOffset += len; + return true; + }, + progressCb, written, error); + + if (!ok) + { + Logger::error(TAG, "Filesystem download/write failed: %s", error.c_str()); + if (errorCb) + errorCb(error.c_str()); + return false; + } + + Logger::info(TAG, "Filesystem flashed successfully (%u bytes)", static_cast(written)); + return true; + } + + void OtaUpdater::runApply(GithubRelease release) + { + // Firmware first (0-50% of progress), then filesystem (50-100%). + // Only reboot once both have succeeded, so the device never boots + // with a firmware/web-UI version mismatch. + // Filesystem first, firmware last. Update.end() flips the boot + // partition immediately on success (esp_ota_set_boot_partition), so + // that must be the very last thing that can fail - if it already + // succeeded and the filesystem write failed afterwards, the device + // would keep running old firmware but boot into new firmware on its + // next (possibly unrelated, e.g. power-loss) reset, serving it + // against a mismatched/old web UI with no way to retry until it's + // back online. Flashing filesystem first means any failure before + // the firmware write leaves the boot partition untouched. + if (!downloadAndFlashFilesystem(release.fsAssetUrl, release.fsAssetSize, 0, 50)) + { + currentPhase = Phase::Error; + return; + } + + if (!downloadAndFlashFirmware(release.firmwareAssetUrl, release.firmwareAssetSize, 50, 99)) + { + currentPhase = Phase::Error; + return; + } + + Logger::info(TAG, "GitHub OTA update to %s successful, rebooting...", release.tag.c_str()); + currentPhase = Phase::Done; + if (progressCb) + progressCb(100); + if (restartCb) + restartCb(); + } + +} // namespace SQM diff --git a/src/WebServer.cpp b/src/WebServer.cpp index a667671..a735289 100644 --- a/src/WebServer.cpp +++ b/src/WebServer.cpp @@ -247,6 +247,14 @@ namespace SQM wifiConnectStartedAt(0) { refreshSensorSnapshot(0); + + otaUpdater = std::make_unique( + [this](int percent) + { setOTAProgress(percent); }, + [this](const char *message) + { setOTAError(message); }, + [] + { WebServer::scheduleRestart(1000); }); } WebServer::~WebServer() @@ -269,6 +277,7 @@ namespace SQM setupAPIRoutes(); setupWebSocket(); setupOTA(); + setupGithubUpdates(); setupStaticRoutes(); // Must be last - has catch-all serveStatic // SPA fallback - serve index.html for any non-API routes @@ -669,6 +678,90 @@ namespace SQM } }); } + void WebServer::setupGithubUpdates() + { + // Check for available releases on the given track (?track=stable|beta, + // defaults to stable). Returns the filtered release list; staleness + // relative to the running firmware is computed client-side. + server.on("/api/updates/check", HTTP_GET, [this](AsyncWebServerRequest *request) + { + if (!requireAuth(request)) + return; + + std::string track = "stable"; + if (request->hasParam("track")) { + String t = request->getParam("track")->value(); + if (t == "beta") track = "beta"; + } + + std::string error; + std::vector releases = otaUpdater->checkForUpdate(track, error); + + if (!error.empty()) { + AsyncWebServerResponse *response = request->beginResponse( + 502, "application/json", createErrorJson(error.c_str()).c_str()); + request->send(response); + return; + } + + DynamicJsonDocument doc(8192); + JsonArray arr = doc.to(); + for (const GithubRelease &r : releases) { + JsonObject o = arr.createNestedObject(); + o["tag"] = r.tag; + o["name"] = r.name; + o["prerelease"] = r.prerelease; + o["publishedAt"] = r.publishedAt; + o["firmwareAssetUrl"] = r.firmwareAssetUrl; + o["firmwareAssetSize"] = r.firmwareAssetSize; + o["fsAssetUrl"] = r.fsAssetUrl; + o["fsAssetSize"] = r.fsAssetSize; + } + + std::string json; + serializeJson(doc, json); + request->send(200, "application/json", json.c_str()); }); + + // Starts a self-download+flash of the given release's firmware AND + // filesystem assets as one atomic update (never just one, to avoid + // frontend/backend drift). Body: {"firmwareAssetUrl", "firmwareAssetSize", + // "fsAssetUrl", "fsAssetSize"}. Progress/errors are pushed over + // /ws/status ("ota_progress" messages), same channel the manual + // upload OTA flow already uses. + AsyncCallbackJsonWebHandler *applyHandler = new AsyncCallbackJsonWebHandler( + "/api/updates/apply", + [this](AsyncWebServerRequest *request, JsonVariant &json) + { + if (!requireAuth(request)) + return; + + JsonObject body = json.as(); + + GithubRelease release; + release.firmwareAssetUrl = std::string(body["firmwareAssetUrl"] | ""); + release.firmwareAssetSize = body["firmwareAssetSize"] | 0; + release.fsAssetUrl = std::string(body["fsAssetUrl"] | ""); + release.fsAssetSize = body["fsAssetSize"] | 0; + + if (release.firmwareAssetUrl.empty() || release.fsAssetUrl.empty()) + { + request->send(400, "application/json", + createErrorJson("firmwareAssetUrl and fsAssetUrl are required").c_str()); + return; + } + + if (!otaUpdater->applyUpdate(release)) + { + request->send(409, "application/json", createErrorJson("Update already in progress").c_str()); + return; + } + + request->send(200, "application/json", "{\"success\":true,\"message\":\"Update started\"}"); + }); + applyHandler->setMethod(HTTP_POST); + server.addHandler(applyHandler); + } + void WebServer::handleGetStatus(AsyncWebServerRequest *request) { std::string json = createStatusJson(); diff --git a/web/src/__tests__/versionCompare.test.ts b/web/src/__tests__/versionCompare.test.ts new file mode 100644 index 0000000..c9d3716 --- /dev/null +++ b/web/src/__tests__/versionCompare.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { isVersionStale } from '../utils/versionCompare'; + +describe('isVersionStale', () => { + it('flags a newer patch version as stale', () => { + expect(isVersionStale('0.0.2', 'v0.0.3')).toBe(true); + }); + + it('flags a newer minor/major version as stale', () => { + expect(isVersionStale('0.0.9', 'v0.1.0')).toBe(true); + expect(isVersionStale('1.9.9', 'v2.0.0')).toBe(true); + }); + + it('does not flag the same version as stale', () => { + expect(isVersionStale('0.0.2', 'v0.0.2')).toBe(false); + }); + + it('does not flag an older release as stale', () => { + expect(isVersionStale('0.0.3', 'v0.0.2')).toBe(false); + }); + + it('handles tags without a v prefix', () => { + expect(isVersionStale('0.0.2', '0.0.3')).toBe(true); + }); + + it('handles differing segment counts', () => { + expect(isVersionStale('0.0', 'v0.0.1')).toBe(true); + expect(isVersionStale('0.0.0', 'v0.0')).toBe(false); + }); + + it('fails safe (not stale) on malformed input', () => { + expect(isVersionStale('0.0.2', 'not-a-version')).toBe(false); + expect(isVersionStale('garbage', 'v0.0.3')).toBe(false); + }); +}); diff --git a/web/src/components/Updates.tsx b/web/src/components/Updates.tsx index 2b1c3ed..a50def7 100644 --- a/web/src/components/Updates.tsx +++ b/web/src/components/Updates.tsx @@ -1,7 +1,245 @@ import { FunctionalComponent } from 'preact'; import { useState, useEffect } from 'preact/hooks'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { isVersionStale } from '../utils/versionCompare'; +import type { GithubRelease, SystemStatus } from '../types'; type UpdateType = 'firmware' | 'filesystem'; +type ReleaseTrack = 'stable' | 'beta'; +type StatusMessage = SystemStatus | { type: 'ota_progress'; progress: number } | { error: string }; + +const GithubUpdates: FunctionalComponent = () => { + const { data: statusMsg } = useWebSocket('/ws/status'); + const currentStatus = statusMsg && 'firmware' in statusMsg ? statusMsg : null; + const currentVersion = currentStatus?.firmware?.version; + + const [track, setTrack] = useState('stable'); + const [releases, setReleases] = useState([]); + const [checking, setChecking] = useState(false); + const [checkError, setCheckError] = useState(''); + const [selectedTag, setSelectedTag] = useState(''); + const [applying, setApplying] = useState(false); + const [applyProgress, setApplyProgress] = useState(0); + const [applyStatus, setApplyStatus] = useState(''); + const [waitingForReboot, setWaitingForReboot] = useState(false); + + const isDemoMode = import.meta.env.VITE_DEMO_MODE === 'true'; + + const checkForUpdates = async (selectedTrack: ReleaseTrack) => { + setChecking(true); + setCheckError(''); + try { + const response = await fetch(`/api/updates/check?track=${selectedTrack}`); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${response.status}`); + } + const data: GithubRelease[] = await response.json(); + setReleases(data); + setSelectedTag(data[0]?.tag ?? ''); + } catch (error) { + setCheckError(`Failed to check for updates: ${error}`); + setReleases([]); + } finally { + setChecking(false); + } + }; + + useEffect(() => { + checkForUpdates(track); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [track]); + + // Reflect firmware-pushed OTA progress/errors from /ws/status while applying + useEffect(() => { + if (!applying || !statusMsg) return; + if ('type' in statusMsg && statusMsg.type === 'ota_progress') { + setApplyProgress(statusMsg.progress); + setApplyStatus(`Applying update... ${statusMsg.progress}%`); + if (statusMsg.progress >= 100) { + setApplyStatus('Update complete! Device is rebooting...'); + setApplying(false); + setWaitingForReboot(true); + } + } else if ('error' in statusMsg) { + setApplyStatus(`Update failed: ${statusMsg.error}`); + setApplying(false); + } + }, [statusMsg, applying]); + + useEffect(() => { + let checkInterval: number | undefined; + + if (waitingForReboot) { + checkInterval = window.setInterval(async () => { + try { + const response = await fetch('/api/status'); + if (response.ok) { + setApplyStatus('Update successful. Device is back online.'); + setWaitingForReboot(false); + window.clearInterval(checkInterval); + } + } catch { + // Still offline, keep waiting + } + }, 2000); + } + + return () => { + if (checkInterval) window.clearInterval(checkInterval); + }; + }, [waitingForReboot]); + + const selectedRelease = releases.find((r) => r.tag === selectedTag); + const stale = currentVersion && selectedRelease ? isVersionStale(currentVersion, selectedRelease.tag) : false; + + const applyUpdate = async () => { + if (!selectedRelease) return; + + setApplying(true); + setApplyProgress(0); + setApplyStatus('Starting update...'); + + // Demo mode: the firmware isn't real, so fake progress the same way the + // manual upload flow does (MSW can't push simulated WebSocket progress + // messages timed against a fake download). + if (isDemoMode) { + let p = 0; + const iv = setInterval(() => { + p = Math.min(p + Math.random() * 12 + 6, 100); + const rounded = Math.round(p); + setApplyProgress(rounded); + setApplyStatus(`Applying update... ${rounded}%`); + if (p >= 100) { + clearInterval(iv); + setApplyStatus('Update complete! Device is rebooting...'); + setApplying(false); + setWaitingForReboot(true); + } + }, 250); + return; + } + + try { + const response = await fetch('/api/updates/apply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firmwareAssetUrl: selectedRelease.firmwareAssetUrl, + firmwareAssetSize: selectedRelease.firmwareAssetSize, + fsAssetUrl: selectedRelease.fsAssetUrl, + fsAssetSize: selectedRelease.fsAssetSize, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.success !== true) { + setApplyStatus(`Failed to start update: ${body.error || `HTTP ${response.status}`}`); + setApplying(false); + return; + } + setApplyStatus('Update started, downloading on device...'); + } catch (error) { + setApplyStatus(`Failed to start update: ${error}`); + setApplying(false); + } + }; + + return ( +
+

Check for Updates

+ +
+ Current version: + {currentVersion ? `v${currentVersion}` : 'unknown'} +
+ +
+ + +
+ + {checking &&

Checking GitHub for releases...

} + + {checkError && ( +
+ {checkError} +
+ )} + + {!checking && !checkError && releases.length === 0 && ( +

No {track} releases available.

+ )} + + {releases.length > 0 && ( +
+
+ + +
+ + {selectedRelease && ( +
+ {stale ? `A newer release (${selectedRelease.tag}) is available.` : 'You are on the latest selected release.'} +
+ )} + + {applying && ( +
+
+ Applying... + {applyProgress}% +
+
+
+
+
+ )} + + {applyStatus && ( +
+ {applyStatus} +
+ )} + + +
+ )} +
+ ); +}; const Updates: FunctionalComponent = () => { const [updateType, setUpdateType] = useState('firmware'); @@ -153,6 +391,8 @@ const Updates: FunctionalComponent = () => {

+ + {/* Update Type Selection */}

Update Type

diff --git a/web/src/components/__tests__/Updates.test.tsx b/web/src/components/__tests__/Updates.test.tsx new file mode 100644 index 0000000..049df89 --- /dev/null +++ b/web/src/components/__tests__/Updates.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/preact'; +import { http, HttpResponse } from 'msw'; +import Updates from '../Updates'; +import { server } from '../../test/mswServer'; +import { mockGithubReleases } from '../../mocks/data'; + +vi.mock('../../hooks/useWebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +import { useWebSocket } from '../../hooks/useWebSocket'; + +describe('Updates - GitHub check for updates', () => { + beforeEach(() => { + vi.mocked(useWebSocket).mockReturnValue({ + data: { firmware: { name: 'SQMeter', version: '0.0.1', buildDate: 'x', buildTime: 'y' } }, + connected: true, + lastMessageAt: Date.now(), + }); + }); + + it('shows the stable release list by default and flags it as newer', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /v0\.0\.3/ })).toBeInTheDocument(); + }); + + expect(screen.getByText(/A newer release/)).toBeInTheDocument(); + }); + + it('switches to the beta track and fetches beta releases', async () => { + render(); + + await waitFor(() => expect(screen.getByRole('option', { name: /v0\.0\.3/ })).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('Release track'), { target: { value: 'beta' } }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /v0\.0\.4-beta\.1/ })).toBeInTheDocument(); + }); + }); + + it('shows an error if the release check fails', async () => { + server.use( + http.get('/api/updates/check', () => + HttpResponse.json({ error: 'GitHub API request failed (HTTP 503)' }, { status: 502 }) + ) + ); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Failed to check for updates/)).toBeInTheDocument(); + }); + }); + + it('starts an update when the apply button is clicked', async () => { + let applyCalled = false; + server.use( + http.post('/api/updates/apply', async ({ request }) => { + const body = await request.json(); + applyCalled = true; + expect(body).toMatchObject({ + firmwareAssetUrl: mockGithubReleases[0].firmwareAssetUrl, + fsAssetUrl: mockGithubReleases[0].fsAssetUrl, + }); + return HttpResponse.json({ success: true, message: 'Update started' }); + }) + ); + + render(); + + await waitFor(() => expect(screen.getByRole('option', { name: /v0\.0\.3/ })).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /Update to v0\.0\.3/ })); + + await waitFor(() => expect(applyCalled).toBe(true)); + }); +}); diff --git a/web/src/mocks/data.ts b/web/src/mocks/data.ts index 733b0cb..7664ecd 100644 --- a/web/src/mocks/data.ts +++ b/web/src/mocks/data.ts @@ -1,4 +1,4 @@ -import type { SensorData, SystemStatus, Config } from "../types"; +import type { SensorData, SystemStatus, Config, GithubRelease } from "../types"; const jitter = (base: number, range: number) => base + (Math.random() - 0.5) * range; @@ -361,3 +361,26 @@ export const mockWifiNetworks = [ { ssid: "NeighbourNet", rssi: -71, encryption: "WPA2" }, { ssid: "TeleCom_5G", rssi: -85, encryption: "WPA3" }, ]; + +export const mockGithubReleases: GithubRelease[] = [ + { + tag: "v0.0.3", + name: "v0.0.3 - RG-15 diagnostics", + prerelease: false, + publishedAt: "2026-07-01T12:00:00Z", + firmwareAssetUrl: "https://github.com/DeanJ87/SQMeter/releases/download/v0.0.3/sqmeter-firmware-v0.0.3.bin", + firmwareAssetSize: 1273285, + fsAssetUrl: "https://github.com/DeanJ87/SQMeter/releases/download/v0.0.3/sqmeter-littlefs-v0.0.3.bin", + fsAssetSize: 274432, + }, + { + tag: "v0.0.4-beta.1", + name: "v0.0.4-beta.1 - Alpaca preview", + prerelease: true, + publishedAt: "2026-08-10T09:30:00Z", + firmwareAssetUrl: "https://github.com/DeanJ87/SQMeter/releases/download/v0.0.4-beta.1/sqmeter-firmware-v0.0.4-beta.1.bin", + firmwareAssetSize: 1301022, + fsAssetUrl: "https://github.com/DeanJ87/SQMeter/releases/download/v0.0.4-beta.1/sqmeter-littlefs-v0.0.4-beta.1.bin", + fsAssetSize: 280100, + }, +]; diff --git a/web/src/mocks/handlers.ts b/web/src/mocks/handlers.ts index b3d6cd3..2670827 100644 --- a/web/src/mocks/handlers.ts +++ b/web/src/mocks/handlers.ts @@ -4,6 +4,7 @@ import { mockStatus, mockConfig, mockWifiNetworks, + mockGithubReleases, } from "./data"; // WebSocket handlers — wildcard host works on both localhost and GitHub Pages @@ -68,6 +69,15 @@ export const handlers = [ http.post("/api/update", () => HttpResponse.json({ success: true })), http.post("/api/update/fs", () => HttpResponse.json({ success: true })), + // REST — GitHub release updates + http.get("/api/updates/check", ({ request }) => { + const track = new URL(request.url).searchParams.get("track") === "beta" ? "beta" : "stable"; + return HttpResponse.json(mockGithubReleases.filter((r) => r.prerelease === (track === "beta"))); + }), + http.post("/api/updates/apply", () => + HttpResponse.json({ success: true, message: "Update started" }) + ), + // WebSocket — push sensor data every second sensorSocket.addEventListener("connection", ({ client }) => { let lastSensorData = generateSensorData(); diff --git a/web/src/test/mswServer.ts b/web/src/test/mswServer.ts new file mode 100644 index 0000000..6606fa4 --- /dev/null +++ b/web/src/test/mswServer.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from '../mocks/handlers'; + +export const server = setupServer(...handlers); diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index f149f27..7fa8ee9 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1 +1,11 @@ import "@testing-library/jest-dom/vitest"; +import { beforeAll, afterEach, afterAll } from "vitest"; +import { cleanup } from "@testing-library/preact"; +import { server } from "./mswServer"; + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => { + server.resetHandlers(); + cleanup(); +}); +afterAll(() => server.close()); diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 176c352..6557cd4 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -400,3 +400,14 @@ export interface WiFiNetwork { rssi: number; encryption: string; } + +export interface GithubRelease { + tag: string; + name: string; + prerelease: boolean; + publishedAt: string; + firmwareAssetUrl: string; + firmwareAssetSize: number; + fsAssetUrl: string; + fsAssetSize: number; +} diff --git a/web/src/utils/versionCompare.ts b/web/src/utils/versionCompare.ts new file mode 100644 index 0000000..bb86032 --- /dev/null +++ b/web/src/utils/versionCompare.ts @@ -0,0 +1,25 @@ +/** + * Compares a running firmware version (e.g. "0.0.2") against a GitHub + * release tag (e.g. "v0.0.3") and returns true if the tag is newer. + * Malformed input is treated as not-stale (fails safe: no update nagging). + */ +export const isVersionStale = (currentVersion: string, latestTag: string): boolean => { + const parse = (v: string): number[] | null => { + const cleaned = v.trim().replace(/^v/i, ''); + if (!/^\d+(\.\d+)*$/.test(cleaned)) return null; + return cleaned.split('.').map(Number); + }; + + const current = parse(currentVersion); + const latest = parse(latestTag); + if (!current || !latest) return false; + + const length = Math.max(current.length, latest.length); + for (let i = 0; i < length; i++) { + const c = current[i] ?? 0; + const l = latest[i] ?? 0; + if (l > c) return true; + if (l < c) return false; + } + return false; +};