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
7 changes: 7 additions & 0 deletions docs/TECH_DEBT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<sha1[:8]>`), 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,
Expand Down
30 changes: 24 additions & 6 deletions scripts/sync_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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=<hash>."""
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=<hash> 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")
Expand Down
20 changes: 18 additions & 2 deletions src/api/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "../logging.h"
#include "../storage.h"
#include "../lume.h"
#include "../network/wifi.h"

// External globals
extern Config config;
Expand Down Expand Up @@ -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
Expand All @@ -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\"}");
Expand Down
21 changes: 19 additions & 2 deletions src/api/status.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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<JsonObject>();
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();
Expand Down
15 changes: 13 additions & 2 deletions src/network/server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
23 changes: 23 additions & 0 deletions src/network/wifi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@
#include "../protocols/sacn.h"
#include "../protocols/mqtt.h"
#include <WiFi.h>
#include <atomic>

// 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<bool> staConnectRequested{false};

void requestWifiConnect() {
staConnectRequested.store(true);
}

// Access Point settings
#define AP_SSID "LUME-Setup"
#define AP_PASSWORD "ledcontrol"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/network/wifi.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();
4 changes: 4 additions & 0 deletions src/storage.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const char*>()) {
config.wifiSSID = doc["wifiSSID"].as<String>();
}
Expand Down
26 changes: 20 additions & 6 deletions ui-concepts/console-euclid-live/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
});
});

Expand Down
2 changes: 1 addition & 1 deletion ui-concepts/console-euclid-live/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@
<div class="jack-field">
<span class="jack-label">SSID</span>
<div class="jack-input-wrap">
<input type="text" class="jack-input" id="wifiSsid" value="LUME-Studio" />
<input type="text" class="jack-input" id="wifiSsid" value="" placeholder="Network name" />
</div>
</div>
<div class="jack-field">
Expand Down
9 changes: 5 additions & 4 deletions ui-concepts/euclid-live/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand Down Expand Up @@ -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 = "";
});
});
Expand Down
2 changes: 1 addition & 1 deletion ui-concepts/euclid-live/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ <h2 class="settings-title">Appendix — Configuration</h2>
<div class="form-plate">
<label class="field">
<span class="mono-label">SSID</span>
<input type="text" id="wifi-ssid" value="LUME-Workshop" class="field__input" />
<input type="text" id="wifi-ssid" value="" placeholder="Network name" class="field__input" />
</label>
<label class="field">
<span class="mono-label">PASSWORD (LEAVE BLANK TO KEEP)</span>
Expand Down
Loading