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"
+ "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\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;
+};