diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index c4f6d06..3303913 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -87,6 +87,13 @@ is implemented but not yet exercised on hardware. Known hardening path, in prior `setFilter([]{ return !updaterInProgress(); })`, so asset reads fall through to the `onNotFound` 503 guard during an FS flash instead of reading a partition mid-erase. +~~**Stale-asset cache**~~ (found + RESOLVED 2026-07-28) — `/assets/` was served with +`max-age=604800` and stable filenames, so browsers kept running week-old `app.js`/`engine.js` +after any UI change (including OTA fs updates). `scripts/sync_web.py` now stamps every asset +reference with a per-file content hash (`?v=`), and the HTML entry points are served +`Cache-Control: no-cache` so a cached page can never pin an old asset set. The week-long +max-age on `/assets/` stays. + **Deferred from the 2026-07-23 branch audit (LOW / nits):** manifest fetched via unbounded `getString()` (MITM-gated OOM risk on the ~130 KB-heap C3 — add a size cap); the `applyTarget` poller can misreport a suspiciously-fast reboot as "failed" (`sawFlashing` never set — cosmetic, diff --git a/scripts/sync_web.py b/scripts/sync_web.py index 4620e5b..8e5b3e3 100644 --- a/scripts/sync_web.py +++ b/scripts/sync_web.py @@ -22,6 +22,7 @@ Run: python3 scripts/sync_web.py (from repo root) Gzip is applied separately at `pio run -t uploadfs` by scripts/gzip_web_files.py. """ +import hashlib import re import shutil from pathlib import Path @@ -34,6 +35,13 @@ ENGINE_DEVICE_PATH = "/assets/engine.js" +def vtag(path: Path) -> str: + """8-hex content hash used as the ?v= cache-buster on asset URLs. Changes + exactly when the served bytes change, so /assets/ keeps its week-long + max-age without ever pinning a browser to stale JS/CSS.""" + return hashlib.sha1(path.read_bytes()).hexdigest()[:8] + + def write(dst: Path, text: str): dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text(text, encoding="utf-8") @@ -47,24 +55,34 @@ def copy(src: Path, dst: Path): def sync_console(): - """console-euclid-live → root. Assets are rewritten to /assets/*.""" + """console-euclid-live → root. Assets are rewritten to /assets/*?v=.""" print("console-euclid-live → data/ (root)") skin = SRC / "console-euclid-live" html = (skin / "index.html").read_text(encoding="utf-8") - html = html.replace("../_engine/engine.js", ENGINE_DEVICE_PATH) - html = re.sub(r'href="styles\.css"', 'href="/assets/app.css"', html) - html = re.sub(r'src="app\.js"', 'src="/assets/app.js"', html) + html = html.replace("../_engine/engine.js", + f"{ENGINE_DEVICE_PATH}?v={vtag(ENGINE_SRC)}") + html = re.sub(r'href="styles\.css"', + f'href="/assets/app.css?v={vtag(skin / "styles.css")}"', html) + html = re.sub(r'src="app\.js"', + f'src="/assets/app.js?v={vtag(skin / "app.js")}"', html) write(DATA / "index.html", html) copy(skin / "styles.css", DATA / "assets" / "app.css") copy(skin / "app.js", DATA / "assets" / "app.js") def sync_euclid(): - """euclid-live → /euclid/. style.css + app.js stay relative; engine absolute.""" + """euclid-live → /euclid/. style.css + app.js stay relative; engine absolute. + All refs get ?v= stamps too — /euclid/ isn't long-cached today, but the + stamps make that safe to change and defeat any heuristic caching.""" print("euclid-live → data/euclid/") skin = SRC / "euclid-live" html = (skin / "index.html").read_text(encoding="utf-8") - html = html.replace("../_engine/engine.js", ENGINE_DEVICE_PATH) + html = html.replace("../_engine/engine.js", + f"{ENGINE_DEVICE_PATH}?v={vtag(ENGINE_SRC)}") + html = re.sub(r'href="style\.css"', + f'href="style.css?v={vtag(skin / "style.css")}"', html) + html = re.sub(r'src="app\.js"', + f'src="app.js?v={vtag(skin / "app.js")}"', html) write(DATA / "euclid" / "index.html", html) copy(skin / "style.css", DATA / "euclid" / "style.css") copy(skin / "app.js", DATA / "euclid" / "app.js") diff --git a/src/api/config.cpp b/src/api/config.cpp index 501259a..7e05603 100644 --- a/src/api/config.cpp +++ b/src/api/config.cpp @@ -4,6 +4,7 @@ #include "../logging.h" #include "../storage.h" #include "../lume.h" +#include "../network/wifi.h" // External globals extern Config config; @@ -58,9 +59,16 @@ void handleApiConfigPost(AsyncWebServerRequest* request, uint8_t* data, size_t l return; } - // Update config + // Update config. Track whether the WiFi credentials change so the loop + // task can kick off a connect attempt with them right away — nothing + // else triggers one, and the periodic retry is (correctly) suppressed + // while the provisioning phone sits on the SoftAP. + String prevWifiSsid = config.wifiSSID; + String prevWifiPass = config.wifiPassword; storage.configFromJson(config, doc); - + bool wifiCredsChanged = (config.wifiSSID != prevWifiSsid || + config.wifiPassword != prevWifiPass); + // Save to storage if (storage.saveConfig(config)) { // ledCount is NOT applied live: it's bound to FastLED at boot via @@ -83,6 +91,14 @@ void handleApiConfigPost(AsyncWebServerRequest* request, uint8_t* data, size_t l // Same story for dim-to-warm strength: live, via the bus. lume::controller.enqueueCommand(lume::Command::setWarmth(config.warmth)); + // New WiFi credentials: have the loop task connect with them now. + // Radio calls must not happen on this (AsyncTCP) task, and the + // maintenance retry alone would never fire during provisioning + // (client parked on the SoftAP suppresses it). + if (wifiCredsChanged && config.wifiSSID.length() > 0) { + requestWifiConnect(); + } + request->send(200, "application/json", "{\"success\":true}"); } else { request->send(500, "application/json", "{\"error\":\"Failed to save\"}"); diff --git a/src/api/status.cpp b/src/api/status.cpp index a5d2750..8ab7503 100644 --- a/src/api/status.cpp +++ b/src/api/status.cpp @@ -21,7 +21,13 @@ void handleRoot(AsyncWebServerRequest* request) { return; } if (webUiAvailable && LittleFS.exists("/index.html")) { - request->send(LittleFS, "/index.html", "text/html; charset=utf-8"); + AsyncWebServerResponse* response = + request->beginResponse(LittleFS, "/index.html", "text/html; charset=utf-8"); + // no-cache: the page carries the ?v= cache-busted asset URLs, so a + // cached copy would pin clients to an old asset set (the /assets/ + // files themselves are cached for a week by design). + response->addHeader("Cache-Control", "no-cache"); + request->send(response); return; } request->send(503, "text/plain", "Web UI not available"); @@ -33,7 +39,18 @@ void handleApiStatus(AsyncWebServerRequest* request) { doc["version"] = FIRMWARE_VERSION; doc["buildHash"] = FIRMWARE_BUILD_HASH; doc["uptime"] = millis() / 1000; - doc["wifi"] = wifiConnected ? "Connected" : "AP Mode"; + // `wifi` is an object — the shape API_V2.md documents and both skins (and + // the mock dev server) already read; the old string ("Connected"/"AP Mode") + // never matched them. `ssid` is the *configured* SSID so the setup page can + // prefill and show connect progress while the device is still AP-only. + JsonObject wifi = doc["wifi"].to(); + wifi["connected"] = wifiConnected; + wifi["ssid"] = config.wifiSSID; + // rssi only while connected: the skins treat its presence as "has signal" + // (a literal 0 would render as a full-strength "0 dBm"). + if (wifiConnected) { + wifi["rssi"] = WiFi.RSSI(); + } doc["ip"] = wifiConnected ? WiFi.localIP().toString() : WiFi.softAPIP().toString(); doc["heap"] = ESP.getFreeHeap(); doc["ledCount"] = lume::controller.getLedCount(); diff --git a/src/network/server.cpp b/src/network/server.cpp index e1d8ce3..343f30f 100644 --- a/src/network/server.cpp +++ b/src/network/server.cpp @@ -336,13 +336,24 @@ void setupServer() { } if (LittleFS.exists(path)) { - request->send(LittleFS, path, contentTypeFromPath(path)); + AsyncWebServerResponse* response = + request->beginResponse(LittleFS, path, contentTypeFromPath(path)); + // HTML entry points (e.g. /euclid/index.html) must revalidate: + // they carry the ?v= cache-busted asset URLs, so a cached page + // would pin clients to an old asset set. + if (path.endsWith(".html")) { + response->addHeader("Cache-Control", "no-cache"); + } + request->send(response); return; } // SPA fallback: serve index for client-side routes without extensions if (path.indexOf('.') < 0 && LittleFS.exists("/index.html")) { - request->send(LittleFS, "/index.html", "text/html; charset=utf-8"); + AsyncWebServerResponse* response = + request->beginResponse(LittleFS, "/index.html", "text/html; charset=utf-8"); + response->addHeader("Cache-Control", "no-cache"); + request->send(response); return; } diff --git a/src/network/wifi.cpp b/src/network/wifi.cpp index b6dd1fe..fd2cf0f 100644 --- a/src/network/wifi.cpp +++ b/src/network/wifi.cpp @@ -6,12 +6,22 @@ #include "../protocols/sacn.h" #include "../protocols/mqtt.h" #include +#include // External globals extern Config config; extern bool wifiConnected; extern unsigned long lastWifiAttempt; +// One-shot STA connect request, set from the web task after a credential save +// and consumed on the loop task. Atomic so the flag (and, via its ordering, the +// config Strings written before it) is safely visible across tasks. +static std::atomic staConnectRequested{false}; + +void requestWifiConnect() { + staConnectRequested.store(true); +} + // Access Point settings #define AP_SSID "LUME-Setup" #define AP_PASSWORD "ledcontrol" @@ -98,6 +108,19 @@ void setupWiFi() { // Helper function for WiFi reconnection and status monitoring void handleWifiMaintenance() { + // User-initiated connect: WiFi credentials were just saved. Fire immediately, + // even with a client parked on the SoftAP — this is the one scan provisioning + // NEEDS. The brief AP blip is deliberate; the alternative (waiting for the + // phone to leave the AP before ever trying) is a setup flow that never + // visibly completes. disconnect() first so a switch away from a currently + // connected network takes effect too. + if (staConnectRequested.exchange(false) && config.wifiSSID.length() > 0) { + LOG_INFO(LogTag::WIFI, "Credentials changed; connecting to %s", config.wifiSSID.c_str()); + WiFi.disconnect(); + WiFi.begin(config.wifiSSID.c_str(), config.wifiPassword.c_str()); + lastWifiAttempt = millis(); + } + // WiFi reconnection logic. While a client is connected to the SoftAP, SKIP the // reconnect entirely: WiFi.begin() channel-hops the single radio to scan, which // drops the AP client mid-DHCP — the exact provisioning failure this addresses (an diff --git a/src/network/wifi.h b/src/network/wifi.h index 93a8ec4..3cac742 100644 --- a/src/network/wifi.h +++ b/src/network/wifi.h @@ -6,6 +6,14 @@ void setupWiFi(); // WiFi reconnection and status monitoring (call from main loop) void handleWifiMaintenance(); +// Ask the loop task to (re)connect the station with the current credentials in +// `config` on its next handleWifiMaintenance() pass. Safe to call from the web +// task — it only sets a flag; all radio work stays on the loop task. Used after +// a config save changes the WiFi credentials, so provisioning connects NOW +// instead of waiting for a retry that never fires while the provisioning phone +// is parked on the SoftAP. +void requestWifiConnect(); + // Re-apply sACN/MQTT config from the persisted global config. MUST run on the // loop task (registered as controller.reconfigureProtocolsFn). See P0.8. void applyProtocolConfig(); diff --git a/src/storage.cpp b/src/storage.cpp index 4ff6d62..5cb52ec 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -190,6 +190,10 @@ void Storage::configToJson(const Config& config, JsonDocument& doc, bool maskApi bool Storage::configFromJson(Config& config, const JsonDocument& doc) { // Only update fields that are present + // "wifiSSID" is the one canonical key, and the spelling is load-bearing: + // ArduinoJson is case-sensitive, and a UI sending anything else (PR #29 + // shipped "wifiSsid") silently drops the SSID here. Keep the skins in + // ui-concepts/ matched to this exact key. if (doc["wifiSSID"].is()) { config.wifiSSID = doc["wifiSSID"].as(); } diff --git a/ui-concepts/console-euclid-live/app.js b/ui-concepts/console-euclid-live/app.js index a9c4159..7a508d5 100644 --- a/ui-concepts/console-euclid-live/app.js +++ b/ui-concepts/console-euclid-live/app.js @@ -946,6 +946,10 @@ function formatUptime(seconds) { } let settingsLoaded = false; +// True once the user types in the SSID field; blocks the status-poll prefill +// from overwriting their input. Cleared on a successful save (the configured +// SSID is then what they typed, so tracking may resume). +let wifiSsidEdited = false; function loadSettingsView() { renderSettingsFromState(); if (settingsLoaded) return; @@ -974,7 +978,13 @@ function renderSettingsFromState() { if (t) $("#dUptime").textContent = t; } if (status.ip) $("#dIp").textContent = status.ip; - if (status.wifi && status.wifi.ssid) $("#wifiSsid").value = status.wifi.ssid; + // Prefill the configured SSID only until the user edits the field. This + // re-runs on every status poll, and a focus check alone is not enough: + // mobile blurs the input when the keyboard closes (or the user taps the + // password field), and the next poll would stomp the typed SSID. + if (status.wifi && status.wifi.ssid && !wifiSsidEdited) { + $("#wifiSsid").value = status.wifi.ssid; + } if (status.wifi && status.wifi.rssi != null) { $("#dRssi").textContent = status.wifi.rssi + " dBm"; const pct = clamp((status.wifi.rssi + 90) / 60, 0, 1); @@ -1043,18 +1053,22 @@ $("#ledCount").addEventListener("change", (e) => { }); }); -// WiFi changes restart the device — require an explicit confirm, and never -// send a blank password (omit it to keep the current one). +// WiFi changes make the device try the new network immediately (no restart) — +// require an explicit confirm, and never send a blank password (omit it to +// keep the current one). Key is "wifiSSID": the exact spelling the firmware +// parses (case-sensitive). +$("#wifiSsid").addEventListener("input", () => { wifiSsidEdited = true; }); $("#wifiSave").addEventListener("click", () => { const ssid = $("#wifiSsid").value.trim(); const pass = $("#wifiPass").value; if (!ssid) { showToast("SSID cannot be empty"); return; } - const proceed = window.confirm("Apply Wi-Fi settings and restart the device now?"); + const proceed = window.confirm("Apply Wi-Fi settings? The device will try to join this network now (you may briefly drop off the setup AP)."); if (!proceed) return; - const body = { wifiSsid: ssid }; + const body = { wifiSSID: ssid }; if (pass) body.wifiPassword = pass; engine.saveConfig(body).then((res) => { - showToast(res.ok ? "Wi-Fi settings applied — device restarting" : "Failed to save Wi-Fi settings"); + if (res.ok) wifiSsidEdited = false; + showToast(res.ok ? "Wi-Fi settings saved — device connecting…" : "Failed to save Wi-Fi settings"); }); }); diff --git a/ui-concepts/console-euclid-live/index.html b/ui-concepts/console-euclid-live/index.html index 07c21f6..bec4a14 100644 --- a/ui-concepts/console-euclid-live/index.html +++ b/ui-concepts/console-euclid-live/index.html @@ -232,7 +232,7 @@
SSID
- +
diff --git a/ui-concepts/euclid-live/app.js b/ui-concepts/euclid-live/app.js index 3d9c23a..cca09ca 100644 --- a/ui-concepts/euclid-live/app.js +++ b/ui-concepts/euclid-live/app.js @@ -863,7 +863,7 @@ $("#dmx-toggle").checked = !!cfg.sacnEnabled; $("#dmx-toggle-label").textContent = cfg.sacnEnabled ? "ENABLED" : "DISABLED"; } - if (cfg.wifiSsid != null) $("#wifi-ssid").value = cfg.wifiSsid; + if (cfg.wifiSSID != null) $("#wifi-ssid").value = cfg.wifiSSID; settingsLoaded = true; }); } @@ -944,12 +944,13 @@ var ssid = $("#wifi-ssid").value.trim(); var password = $("#wifi-password").value; if (!ssid) { toast("SSID required"); return; } - var msg = "Changing Wi-Fi credentials will restart the device. Continue?"; + var msg = "Apply Wi-Fi credentials? The device will try to join this network now."; if (!window.confirm(msg)) return; - var body = { wifiSsid: ssid }; + // "wifiSSID" is the exact spelling the firmware parses (case-sensitive). + var body = { wifiSSID: ssid }; if (password) body.wifiPassword = password; // omit when blank — keep current password engine.saveConfig(body).then(function (res) { - toast(res && res.ok ? "Network settings saved — device restarting" : "Failed to save network settings"); + toast(res && res.ok ? "Network settings saved — device connecting…" : "Failed to save network settings"); $("#wifi-password").value = ""; }); }); diff --git a/ui-concepts/euclid-live/index.html b/ui-concepts/euclid-live/index.html index 3b0a730..196992e 100644 --- a/ui-concepts/euclid-live/index.html +++ b/ui-concepts/euclid-live/index.html @@ -217,7 +217,7 @@

Appendix — Configuration