From c656115d1d38d0bcb6d52acdbaf128888ac958ce Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 15:51:43 +0200 Subject: [PATCH 001/125] Enable automatic PlatformIO upload targets --- platformio.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index c601e6a..6766479 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,6 +46,7 @@ extra_scripts = pre:extra_scripts.py [env:esp32-s3] extends = core board = esp32-s3-devkitc1-n16r8 +targets = upload monitor_speed = 115200 monitor_filters = esp32_exception_decoder @@ -53,6 +54,7 @@ monitor_filters = [env:esp32-s3-mini] extends = core board = lolin_s3_mini +targets = upload monitor_speed = 115200 monitor_filters = esp32_exception_decoder @@ -60,4 +62,3 @@ build_flags = ${core.build_flags} -D ARDUINO_USB_CDC_ON_BOOT=1 -D S3MINI=1 - From fc7898d2a4b61e60c96eb67dfd1a33089e260e3d Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:00:46 +0200 Subject: [PATCH 002/125] Add dedicated PlatformIO OTA upload environments --- README.md | 11 +++++++++++ platformio.ini | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ff06119..4dc73be 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,17 @@ Courtesy of [@dlisec](https://github.com/dlisec) A build script has been provided by [@matthew73210](https://github.com/matthew73210), so to get up and running on the ESP, just run `./build_and_flash.sh`. Make sure to read the comments in the script. But also in the platformio.ini and choose the right board +#### OTA upload from VS Code (PlatformIO) + +If you already flashed Yaeger once over USB and it is connected to your network, select one of the OTA environments in +PlatformIO before clicking upload: + +* `esp32-s3-ota` +* `esp32-s3-mini-ota` + +These environments use `upload_protocol = espota` and `upload_port = yaeger.local`, which avoids serial auto-detection +issues (for example selecting `Bluetooth-Incoming-Port` on macOS). + ## Latest features ### PID diff --git a/platformio.ini b/platformio.ini index 6766479..b541d23 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,7 +46,6 @@ extra_scripts = pre:extra_scripts.py [env:esp32-s3] extends = core board = esp32-s3-devkitc1-n16r8 -targets = upload monitor_speed = 115200 monitor_filters = esp32_exception_decoder @@ -54,7 +53,6 @@ monitor_filters = [env:esp32-s3-mini] extends = core board = lolin_s3_mini -targets = upload monitor_speed = 115200 monitor_filters = esp32_exception_decoder @@ -62,3 +60,15 @@ build_flags = ${core.build_flags} -D ARDUINO_USB_CDC_ON_BOOT=1 -D S3MINI=1 + +[env:esp32-s3-ota] +extends = env:esp32-s3 +targets = upload +upload_protocol = espota +upload_port = yaeger.local + +[env:esp32-s3-mini-ota] +extends = env:esp32-s3-mini +targets = upload +upload_protocol = espota +upload_port = yaeger.local From 76b3e41a8b6d98a47e0f1be88e12d5cd6718ee90 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:15:57 +0200 Subject: [PATCH 003/125] Align upload config with ElegantOTA and flash script --- README.md | 13 ++----------- platformio.ini | 14 ++------------ 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4dc73be..2febbd2 100644 --- a/README.md +++ b/README.md @@ -76,17 +76,8 @@ Courtesy of [@dlisec](https://github.com/dlisec) A build script has been provided by [@matthew73210](https://github.com/matthew73210), so to get up and running on the ESP, just run `./build_and_flash.sh`. Make sure to read the comments in the script. But also in the platformio.ini and choose the right board - -#### OTA upload from VS Code (PlatformIO) - -If you already flashed Yaeger once over USB and it is connected to your network, select one of the OTA environments in -PlatformIO before clicking upload: - -* `esp32-s3-ota` -* `esp32-s3-mini-ota` - -These environments use `upload_protocol = espota` and `upload_port = yaeger.local`, which avoids serial auto-detection -issues (for example selecting `Bluetooth-Incoming-Port` on macOS). +Yaeger OTA in this project is provided by the web-based ElegantOTA handler (`/update`) and not the PlatformIO `espota` +upload protocol. ## Latest features diff --git a/platformio.ini b/platformio.ini index b541d23..6766479 100644 --- a/platformio.ini +++ b/platformio.ini @@ -46,6 +46,7 @@ extra_scripts = pre:extra_scripts.py [env:esp32-s3] extends = core board = esp32-s3-devkitc1-n16r8 +targets = upload monitor_speed = 115200 monitor_filters = esp32_exception_decoder @@ -53,6 +54,7 @@ monitor_filters = [env:esp32-s3-mini] extends = core board = lolin_s3_mini +targets = upload monitor_speed = 115200 monitor_filters = esp32_exception_decoder @@ -60,15 +62,3 @@ build_flags = ${core.build_flags} -D ARDUINO_USB_CDC_ON_BOOT=1 -D S3MINI=1 - -[env:esp32-s3-ota] -extends = env:esp32-s3 -targets = upload -upload_protocol = espota -upload_port = yaeger.local - -[env:esp32-s3-mini-ota] -extends = env:esp32-s3-mini -targets = upload -upload_protocol = espota -upload_port = yaeger.local From 14344988cb1496d2b6eea89f211ecb478547a09d Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:16:15 +0200 Subject: [PATCH 004/125] Add PlatformIO custom uploader for ElegantOTA --- README.md | 8 +++ platformio.ini | 18 +++++ scripts/elegantota_upload.py | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 scripts/elegantota_upload.py diff --git a/README.md b/README.md index 2febbd2..0396045 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,14 @@ ESP, just run `./build_and_flash.sh`. Make sure to read the comments in the scri Yaeger OTA in this project is provided by the web-based ElegantOTA handler (`/update`) and not the PlatformIO `espota` upload protocol. +For VS Code + PlatformIO uploads via ElegantOTA, use one of these environments: + +* `esp32-s3-elegantota` +* `esp32-s3-mini-elegantota` + +These use a custom PlatformIO upload script that sends the built firmware to `http://yaeger.local/update` through the +same ElegantOTA mechanism used by the device web UI. + ## Latest features ### PID diff --git a/platformio.ini b/platformio.ini index 6766479..aa1f0ef 100644 --- a/platformio.ini +++ b/platformio.ini @@ -62,3 +62,21 @@ build_flags = ${core.build_flags} -D ARDUINO_USB_CDC_ON_BOOT=1 -D S3MINI=1 + +[env:esp32-s3-elegantota] +extends = env:esp32-s3 +targets = upload +upload_protocol = custom +custom_upload_url = http://yaeger.local/update +extra_scripts = + ${core.extra_scripts} + post:scripts/elegantota_upload.py + +[env:esp32-s3-mini-elegantota] +extends = env:esp32-s3-mini +targets = upload +upload_protocol = custom +custom_upload_url = http://yaeger.local/update +extra_scripts = + ${core.extra_scripts} + post:scripts/elegantota_upload.py diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py new file mode 100644 index 0000000..b24a60b --- /dev/null +++ b/scripts/elegantota_upload.py @@ -0,0 +1,125 @@ +""" +PlatformIO custom upload command for ElegantOTA (web OTA). + +Usage in platformio.ini: + upload_protocol = custom + custom_upload_url = http://yaeger.local/update + extra_scripts = post:scripts/elegantota_upload.py +""" + +from __future__ import annotations + +import hashlib +import mimetypes +import os +import urllib.error +import urllib.parse +import urllib.request +import uuid + +Import("env") + + +def _multipart(fields: dict[str, str], files: dict[str, tuple[str, bytes, str]]) -> tuple[bytes, str]: + boundary = f"----pio-elegantota-{uuid.uuid4().hex}" + chunks: list[bytes] = [] + + for key, value in fields.items(): + chunks.extend( + [ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode(), + value.encode(), + b"\r\n", + ] + ) + + for key, (filename, data, content_type) in files.items(): + chunks.extend( + [ + f"--{boundary}\r\n".encode(), + ( + f'Content-Disposition: form-data; name="{key}"; ' + f'filename="{filename}"\r\n' + ).encode(), + f"Content-Type: {content_type}\r\n\r\n".encode(), + data, + b"\r\n", + ] + ) + + chunks.append(f"--{boundary}--\r\n".encode()) + body = b"".join(chunks) + return body, boundary + + +def _http_get(url: str) -> tuple[int, bytes]: + req = urllib.request.Request(url=url, method="GET") + with urllib.request.urlopen(req, timeout=30) as response: + return response.status, response.read() + + +def _http_post(url: str, body: bytes, content_type: str) -> tuple[int, bytes]: + req = urllib.request.Request(url=url, method="POST", data=body) + req.add_header("Content-Type", content_type) + req.add_header("Content-Length", str(len(body))) + with urllib.request.urlopen(req, timeout=120) as response: + return response.status, response.read() + + +def _normalise_base_url(custom_upload_url: str) -> str: + parsed = urllib.parse.urlparse(custom_upload_url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"custom_upload_url must start with http:// or https://, got: {custom_upload_url}") + + path = parsed.path or "" + if path.endswith("/update"): + path = path[: -len("/update")] + + return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")).rstrip("/") + + +def on_upload(source, target, env_): # noqa: ANN001 (PlatformIO callback signature) + firmware_path = str(source[0]) + custom_upload_url = env_.GetProjectOption("custom_upload_url") + base_url = _normalise_base_url(custom_upload_url) + + with open(firmware_path, "rb") as firmware_file: + firmware_data = firmware_file.read() + + firmware_md5 = hashlib.md5(firmware_data).hexdigest() + filename = os.path.basename(firmware_path) + mode = "fs" if filename == "spiffs.bin" else "fr" + + start_url = f"{base_url}/ota/start?mode={mode}&hash={firmware_md5}" + upload_url = f"{base_url}/ota/upload" + + print(f"ElegantOTA start: {start_url}") + try: + status, _ = _http_get(start_url) + except urllib.error.URLError as exc: + raise RuntimeError(f"Failed to reach ElegantOTA start endpoint: {exc}") from exc + + if status != 200: + raise RuntimeError(f"ElegantOTA start endpoint returned HTTP {status}") + + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + body, boundary = _multipart( + fields={"MD5": firmware_md5}, + files={"firmware": (filename, firmware_data, content_type)}, + ) + + print(f"ElegantOTA upload: {upload_url}") + try: + status, response = _http_post(upload_url, body, f"multipart/form-data; boundary={boundary}") + except urllib.error.URLError as exc: + raise RuntimeError(f"Failed to upload to ElegantOTA endpoint: {exc}") from exc + + if status != 200: + raise RuntimeError(f"ElegantOTA upload failed with HTTP {status}: {response.decode(errors='ignore')}") + + print("ElegantOTA upload successful.") + return 0 + + +env.Replace(UPLOADCMD=on_upload) From 25f98f50c8cb0c049b72af7a218f62b2b0783782 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:20:24 +0200 Subject: [PATCH 005/125] Fix custom uploader callback signature for SCons --- scripts/elegantota_upload.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py index b24a60b..f24659f 100644 --- a/scripts/elegantota_upload.py +++ b/scripts/elegantota_upload.py @@ -79,9 +79,9 @@ def _normalise_base_url(custom_upload_url: str) -> str: return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")).rstrip("/") -def on_upload(source, target, env_): # noqa: ANN001 (PlatformIO callback signature) +def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signature) firmware_path = str(source[0]) - custom_upload_url = env_.GetProjectOption("custom_upload_url") + custom_upload_url = env.GetProjectOption("custom_upload_url") base_url = _normalise_base_url(custom_upload_url) with open(firmware_path, "rb") as firmware_file: From aaabede6f2499a27215a0f043cb106fb7758a8a4 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:30:12 +0200 Subject: [PATCH 006/125] Add UI/device version and network info metadata --- README.md | 2 + miniweb/package.json | 2 +- miniweb/src/main.ts | 111 ++++++++++++++++++++++++++++++----------- miniweb/vite.config.ts | 76 +++++++++++++++------------- src/api.cpp | 18 ++++++- src/api.h | 2 +- src/version.h | 6 +++ src/wifi_setup.cpp | 33 +++++++++++- src/wifi_setup.h | 7 +++ 9 files changed, 190 insertions(+), 67 deletions(-) create mode 100644 src/version.h diff --git a/README.md b/README.md index 0396045..852af12 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ You can also control Yaeger from its own web interface without an app. Just poin your home wifi, or `192.168.4.1` if Yaeger creates its own access point. ![yaeger webui](./assets/yaeger-webui.png) +The web UI now includes a **Version & Network Info** section that shows the Web UI version/build timestamp and device firmware/network details (mode, SSID, IP, hostname) so you can quickly check when the currently loaded build was last updated. + #### Using Yaeger on the go If Yaeger can't connect to your preferred Wifi, it will create its own access point. Perfect for when out and about :grin: diff --git a/miniweb/package.json b/miniweb/package.json index b43a3f2..58b28fa 100644 --- a/miniweb/package.json +++ b/miniweb/package.json @@ -1,7 +1,7 @@ { "name": "miniweb", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index 35a37a1..bb62a3e 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -5,7 +5,18 @@ import { profile, ProfileControl } from "./profiling.ts"; import { PIDController } from "./pid.ts"; import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; -const { label, button, div, input, p, span, h1, h2 } = van.tags; +declare const __APP_VERSION__: string; +declare const __BUILD_TIMESTAMP__: string; + +interface DeviceInfo { + firmwareVersion: string; + networkMode: string; + ssid: string; + ip: string; + hostname: string; +} + +const { button, div, input, p, span, h1, h2 } = van.tags; // State variables const pidPFactor = van.state(1.0); @@ -16,6 +27,34 @@ const pidDFactor = van.state(0.01); const ssidField = van.state(""); const passField = van.state(""); +// Versioning and network details +const deviceInfo = van.state(null); +const deviceInfoError = van.state(null); + +const appVersion = __APP_VERSION__; +const buildTimestamp = new Date(__BUILD_TIMESTAMP__).toLocaleString(); + +const refreshDeviceInfo = async () => { + try { + deviceInfoError.val = null; + const response = await fetch(`http://${location.host}/api/info`); + if (!response.ok) { + throw new Error(`API returned ${response.status}`); + } + + deviceInfo.val = (await response.json()) as DeviceInfo; + } catch (error: unknown) { + deviceInfo.val = null; + if (error instanceof Error) { + deviceInfoError.val = error.message; + } else { + deviceInfoError.val = "Unknown error"; + } + } +}; + +void refreshDeviceInfo(); + const updateWifiSettings = async () => { const ssid = ssidField.val; const pass = passField.val; @@ -28,6 +67,7 @@ const updateWifiSettings = async () => { alert( "Wifi settings updated!\nPlease restart for the new settings to take effect", ); + await refreshDeviceInfo(); } else { alert(`Something happened: ${response.status}`); } @@ -83,8 +123,8 @@ const ConnectionStatus = () => connectionStatus.val === "Connected" ? "green" : connectionStatus.val === "Error" - ? "red" - : "orange" + ? "red" + : "orange" }`, }, () => connectionStatus.val, @@ -96,37 +136,51 @@ const SensorData = () => div( { class: "sensor-data" }, "Current Readings:", - p( - "ET: ", - () => lastMessage.val?.ET ?? "N/A", - "°C", - ), - p( - "BT: ", - () => lastMessage.val?.BT ?? "N/A", - "°C", - ), - p( - "Last update: ", - () => lastUpdate.val?.toString() ?? "N/A", - ), + p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), + p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), + p("Last update: ", () => lastUpdate.val?.toString() ?? "N/A"), + ); + +const VersionAndNetworkInfo = () => + div( + { class: "section" }, + h2("Version & Network Info"), + p("Web UI version: ", appVersion), + p("Web UI build: ", buildTimestamp), + p("Viewed via: ", location.origin), + () => + deviceInfo.val + ? div( + p("Firmware version: ", deviceInfo.val.firmwareVersion), + p("Network mode: ", deviceInfo.val.networkMode), + p("SSID: ", deviceInfo.val.ssid || "N/A"), + p("IP address: ", deviceInfo.val.ip || "N/A"), + p("Hostname: ", deviceInfo.val.hostname || "N/A"), + ) + : p("Device info unavailable"), + () => + deviceInfoError.val + ? p( + { style: "color: #b91c1c;" }, + "Could not load network info: ", + deviceInfoError.val, + ) + : null, + button({ onclick: refreshDeviceInfo }, "Refresh Info"), ); // Start page UI const startPage = div( - div({ class: "start-page" }, + div( + { class: "start-page" }, h1("Yaeger Roaster Control"), ConnectionStatus, SensorData, - div({ class: "section" }, - h2("Profile Selection"), - ProfileControl, - ), - div({ class: "section" }, - h2("PID Settings"), - PIDConfig, - ), - div({ class: "section" }, + VersionAndNetworkInfo, + div({ class: "section" }, h2("Profile Selection"), ProfileControl), + div({ class: "section" }, h2("PID Settings"), PIDConfig), + div( + { class: "section" }, h2("Wifi Settings"), p(), "Wifi ssid:", @@ -147,7 +201,8 @@ const startPage = div( p(), button({ onclick: updateWifiSettings }, "Update Wifi"), ), - div({ class: "section" }, + div( + { class: "section" }, button( { onclick: () => { diff --git a/miniweb/vite.config.ts b/miniweb/vite.config.ts index 18b9a1e..8e0c7c8 100644 --- a/miniweb/vite.config.ts +++ b/miniweb/vite.config.ts @@ -1,6 +1,6 @@ - -import { defineConfig } from 'vite'; -import { VitePWA } from 'vite-plugin-pwa'; +import { defineConfig } from "vite"; +import { VitePWA } from "vite-plugin-pwa"; +import packageJson from "./package.json"; // This function gets the IP that the Development server will point to from `local.config.ts`. // To change your local IP create a file named `local.config.ts` in the same directory as vite.config.ts // And contents: @@ -9,13 +9,17 @@ import { VitePWA } from 'vite-plugin-pwa'; // export default localConfig; // ------------------------------------------------- async function getDevelopmentIp() { - const defaultTargetIp = 'localhost'; + const defaultTargetIp = "localhost"; try { - const localConfig = await import('./local.config'); - console.info(`Development server proxying to ${localConfig.default.targetIp}`); + const localConfig = await import("./local.config"); + console.info( + `Development server proxying to ${localConfig.default.targetIp}`, + ); return localConfig.default.targetIp; } catch (e) { - console.info(`Did not find local_config.ts file. IP will default to ${defaultTargetIp}`); + console.info( + `Did not find local_config.ts file. IP will default to ${defaultTargetIp}`, + ); return defaultTargetIp; } } @@ -24,21 +28,25 @@ async function getDevelopmentIp() { export default defineConfig(async () => ({ server: { proxy: { - '/api': { + "/api": { target: `http://${await getDevelopmentIp()}`, changeOrigin: true, secure: false, }, - '/ws': { + "/ws": { target: `ws://${await getDevelopmentIp()}`, ws: true, changeOrigin: true, secure: false, }, }, - host: 'localhost', + host: "localhost", port: 3000, }, + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + __BUILD_TIMESTAMP__: JSON.stringify(new Date().toISOString()), + }, plugins: [ // react(), // viteTsconfigPaths(), @@ -46,49 +54,49 @@ export default defineConfig(async () => ({ // viteCompression(), VitePWA({ - registerType: 'autoUpdate', - // workbox: { - // clientsClaim: true, - // skipWaiting: true, - // }, + registerType: "autoUpdate", + // workbox: { + // clientsClaim: true, + // skipWaiting: true, + // }, manifest: { - short_name: 'Yaeger', - name: 'Yaeger web interface', + short_name: "Yaeger", + name: "Yaeger web interface", protocol_handlers: [ { - protocol: 'web+http', - url: '/', + protocol: "web+http", + url: "/", }, ], icons: [ { - src: 'favicon.png', - sizes: '64x64 32x32 24x24 16x16', - type: 'image/png', + src: "favicon.png", + sizes: "64x64 32x32 24x24 16x16", + type: "image/png", }, { - src: 'logo.png', - sizes: 'any', - type: 'image/png', + src: "logo.png", + sizes: "any", + type: "image/png", }, { - src: 'splash.png', - sizes: 'any', - type: 'image/png', + src: "splash.png", + sizes: "any", + type: "image/png", }, ], - start_url: '/', - display: 'standalone', - theme_color: '#000000', - background_color: '#ffffff', + start_url: "/", + display: "standalone", + theme_color: "#000000", + background_color: "#ffffff", }, - manifestFilename: 'manifest.json', + manifestFilename: "manifest.json", injectRegister: null, // Disable SW registration for now // registerType: 'autoUpdate', }), ], build: { - outDir: '../data', + outDir: "../data", emptyOutDir: true, }, })); diff --git a/src/api.cpp b/src/api.cpp index 34f9822..3807a90 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -1,6 +1,9 @@ +#include "api.h" + #include "logging.h" -#include "sensors.h" +#include "version.h" #include "wifi_setup.h" +#include #include #include @@ -25,4 +28,17 @@ void setupApi(AsyncWebServer *server) { prefs.end(); request->send(200); }); + + server->on("/api/info", HTTP_GET, [](AsyncWebServerRequest *request) { + StaticJsonDocument<256> doc; + doc["firmwareVersion"] = YAEGER_FW_VERSION; + doc["networkMode"] = getWifiModeString(); + doc["ssid"] = getActiveSSID(); + doc["ip"] = getActiveIP(); + doc["hostname"] = getConfiguredHostname(); + + String body; + serializeJson(doc, body); + request->send(200, "application/json", body); + }); } diff --git a/src/api.h b/src/api.h index deaeebe..38fc565 100644 --- a/src/api.h +++ b/src/api.h @@ -1,3 +1,3 @@ #include -void setupApi(AsyncWebServer *ws); +void setupApi(AsyncWebServer *server); diff --git a/src/version.h b/src/version.h new file mode 100644 index 0000000..c8ba5c5 --- /dev/null +++ b/src/version.h @@ -0,0 +1,6 @@ +#ifndef YAEGER_VERSION_H +#define YAEGER_VERSION_H + +#define YAEGER_FW_VERSION "0.2.0" + +#endif diff --git a/src/wifi_setup.cpp b/src/wifi_setup.cpp index 38eaa83..5ccf8f2 100644 --- a/src/wifi_setup.cpp +++ b/src/wifi_setup.cpp @@ -26,6 +26,7 @@ class WiFiParams { }; WiFiParams params; +const char *yaegerHostname = "yaeger.local"; void setupAP() { WiFi.mode(WIFI_AP); @@ -69,9 +70,8 @@ void setupWifi() { params.init(); - const char *hostname = "yaeger.local"; WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE, INADDR_NONE); - WiFi.setHostname(hostname); + WiFi.setHostname(yaegerHostname); if (params.hasCredentials()) { log("trying to connect to wifi"); @@ -115,3 +115,32 @@ void WiFiParams::reset() { pass = ""; preferences.clear(); } + + +const char *getWifiModeString() { + wifi_mode_t mode = WiFi.getMode(); + if (mode == WIFI_MODE_AP) + return "AP"; + if (mode == WIFI_MODE_STA) + return "STA"; + if (mode == WIFI_MODE_APSTA) + return "AP+STA"; + + return "UNKNOWN"; +} + +String getActiveSSID() { + if (WiFi.getMode() == WIFI_MODE_AP) + return WiFi.softAPSSID(); + + return WiFi.SSID(); +} + +String getActiveIP() { + if (WiFi.getMode() == WIFI_MODE_AP) + return WiFi.softAPIP().toString(); + + return WiFi.localIP().toString(); +} + +String getConfiguredHostname() { return String(yaegerHostname); } diff --git a/src/wifi_setup.h b/src/wifi_setup.h index 6d77e54..6d18aa9 100644 --- a/src/wifi_setup.h +++ b/src/wifi_setup.h @@ -1,6 +1,8 @@ #ifndef WIFI_SETUP #define WIFI_SETUP +#include + // can take a while - check nvs for stored ssid and pass // check if can connect // if not, start AP mode @@ -10,4 +12,9 @@ extern const char *wifiPrefsKey; extern const char *wifiSSIDKey; extern const char *wifiPassKey; +const char *getWifiModeString(); +String getActiveSSID(); +String getActiveIP(); +String getConfiguredHostname(); + #endif From a1b543ad22efa7fa09980f184fb2d6fb36311511 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:37:48 +0200 Subject: [PATCH 007/125] Add one-shot OTA script for firmware plus web assets --- README.md | 10 ++++++++++ ota_update_all.sh | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100755 ota_update_all.sh diff --git a/README.md b/README.md index 852af12..27a286f 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,16 @@ For VS Code + PlatformIO uploads via ElegantOTA, use one of these environments: These use a custom PlatformIO upload script that sends the built firmware to `http://yaeger.local/update` through the same ElegantOTA mechanism used by the device web UI. +For a **single-command OTA update of the whole project** (frontend files + firmware), run: + +```bash +./ota_update_all.sh s3 +# or +./ota_update_all.sh s3-mini +``` + +This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. + ## Latest features ### PID diff --git a/ota_update_all.sh b/ota_update_all.sh new file mode 100755 index 0000000..3da5c10 --- /dev/null +++ b/ota_update_all.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +# Build web assets + upload LittleFS + upload firmware via ElegantOTA in one run. +# Usage: +# ./ota_update_all.sh + +if [[ -z "${1:-}" ]]; then + echo "Usage: $0 " + exit 1 +fi + +case "$1" in + s3) + PIO_ENV="esp32-s3-elegantota" + ;; + s3-mini) + PIO_ENV="esp32-s3-mini-elegantota" + ;; + *) + echo "Invalid argument: '$1'. Use 's3' or 's3-mini'." + exit 1 + ;; +esac + +echo "Using OTA PlatformIO environment: $PIO_ENV" + +echo "Building miniweb assets..." +pushd miniweb >/dev/null +npm install +npm run build +popd >/dev/null + +echo "Uploading filesystem + firmware via OTA (single run)..." +pio run -e "$PIO_ENV" -t buildfs -t uploadfs -t upload + +echo "OTA update complete (web assets + firmware)." From b0fb4bde3c26d48128a62a71d02854d17ae46b2c Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:44:43 +0200 Subject: [PATCH 008/125] Bootstrap OTA script venv and littlefs dependency --- README.md | 2 +- ota_update_all.sh | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27a286f..46e69cd 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ For a **single-command OTA update of the whole project** (frontend files + firmw ./ota_update_all.sh s3-mini ``` -This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. +This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. The script also creates and uses a local Python virtual environment (`.ota-venv`) and installs required OTA dependencies (`platformio`, `littlefs-python`) automatically. ## Latest features diff --git a/ota_update_all.sh b/ota_update_all.sh index 3da5c10..5f4e216 100755 --- a/ota_update_all.sh +++ b/ota_update_all.sh @@ -5,6 +5,8 @@ set -euo pipefail # Usage: # ./ota_update_all.sh +VENV_DIR=".ota-venv" + if [[ -z "${1:-}" ]]; then echo "Usage: $0 " exit 1 @@ -23,11 +25,39 @@ case "$1" in ;; esac +ensure_ota_venv() { + local python_cmd="${PYTHON_BIN:-python3}" + + if ! command -v "$python_cmd" >/dev/null 2>&1; then + echo "Error: could not find Python executable '$python_cmd'." + exit 1 + fi + + if [[ ! -d "$VENV_DIR" ]]; then + echo "Creating OTA virtual environment in $VENV_DIR..." + "$python_cmd" -m venv "$VENV_DIR" + fi + + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + + echo "Installing OTA toolchain dependencies in venv..." + pip install --upgrade pip setuptools wheel >/dev/null + pip install --upgrade platformio littlefs-python >/dev/null + + if ! command -v pio >/dev/null 2>&1; then + echo "Error: 'pio' is not available in $VENV_DIR after install." + exit 1 + fi +} + echo "Using OTA PlatformIO environment: $PIO_ENV" +ensure_ota_venv + echo "Building miniweb assets..." pushd miniweb >/dev/null -npm install +npm install --no-audit --no-fund npm run build popd >/dev/null From 672b23ef7a0fc53bf0c2e4dbc104592f30c6b55e Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:49:07 +0200 Subject: [PATCH 009/125] Auto-install fatfs deps and retry OTA on missing modules --- README.md | 2 +- ota_update_all.sh | 60 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 46e69cd..5415e55 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ For a **single-command OTA update of the whole project** (frontend files + firmw ./ota_update_all.sh s3-mini ``` -This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. The script also creates and uses a local Python virtual environment (`.ota-venv`) and installs required OTA dependencies (`platformio`, `littlefs-python`) automatically. +This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfsgen`), and auto-retries if PlatformIO reports missing Python modules. ## Latest features diff --git a/ota_update_all.sh b/ota_update_all.sh index 5f4e216..f5606e2 100755 --- a/ota_update_all.sh +++ b/ota_update_all.sh @@ -25,6 +25,20 @@ case "$1" in ;; esac +map_module_to_package() { + case "$1" in + littlefs) + echo "littlefs-python" + ;; + fatfs) + echo "fatfsgen" + ;; + *) + echo "$1" + ;; + esac +} + ensure_ota_venv() { local python_cmd="${PYTHON_BIN:-python3}" @@ -43,7 +57,7 @@ ensure_ota_venv() { echo "Installing OTA toolchain dependencies in venv..." pip install --upgrade pip setuptools wheel >/dev/null - pip install --upgrade platformio littlefs-python >/dev/null + pip install --upgrade platformio littlefs-python fatfsgen >/dev/null if ! command -v pio >/dev/null 2>&1; then echo "Error: 'pio' is not available in $VENV_DIR after install." @@ -51,6 +65,48 @@ ensure_ota_venv() { fi } +run_pio_with_auto_deps() { + local max_attempts=5 + local attempt=1 + + while ((attempt <= max_attempts)); do + local log_file + log_file=$(mktemp) + + echo "PlatformIO attempt $attempt/$max_attempts..." + set +e + pio run -e "$PIO_ENV" -t buildfs -t uploadfs -t upload 2>&1 | tee "$log_file" + local status=${PIPESTATUS[0]} + set -e + + if [[ $status -eq 0 ]]; then + rm -f "$log_file" + return 0 + fi + + local missing_module + missing_module=$(sed -n "s/.*ModuleNotFoundError: No module named '\([^']\+\)'.*/\1/p" "$log_file" | head -n 1) + + if [[ -z "$missing_module" ]]; then + echo "PlatformIO failed, but no missing Python module could be detected." + rm -f "$log_file" + return "$status" + fi + + local missing_package + missing_package=$(map_module_to_package "$missing_module") + + echo "Detected missing Python module '$missing_module'. Installing package '$missing_package' and retrying..." + pip install --upgrade "$missing_package" >/dev/null + + rm -f "$log_file" + attempt=$((attempt + 1)) + done + + echo "Reached retry limit while trying to resolve PlatformIO Python dependencies." + return 1 +} + echo "Using OTA PlatformIO environment: $PIO_ENV" ensure_ota_venv @@ -62,6 +118,6 @@ npm run build popd >/dev/null echo "Uploading filesystem + firmware via OTA (single run)..." -pio run -e "$PIO_ENV" -t buildfs -t uploadfs -t upload +run_pio_with_auto_deps echo "OTA update complete (web assets + firmware)." From 8bc19c545d7c2c9122dc7257ed3df0682bfd4fc1 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:55:13 +0200 Subject: [PATCH 010/125] Handle yaml module errors in OTA dependency bootstrap --- README.md | 2 +- ota_update_all.sh | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5415e55..a99e49c 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ For a **single-command OTA update of the whole project** (frontend files + firmw ./ota_update_all.sh s3-mini ``` -This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfsgen`), and auto-retries if PlatformIO reports missing Python modules. +This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfs-ng`, `pyyaml`), and auto-retries if PlatformIO reports missing Python modules. ## Latest features diff --git a/ota_update_all.sh b/ota_update_all.sh index f5606e2..691bbc7 100755 --- a/ota_update_all.sh +++ b/ota_update_all.sh @@ -31,7 +31,10 @@ map_module_to_package() { echo "littlefs-python" ;; fatfs) - echo "fatfsgen" + echo "fatfs-ng" + ;; + yaml) + echo "pyyaml" ;; *) echo "$1" @@ -39,6 +42,23 @@ map_module_to_package() { esac } +extract_missing_module() { + local log_file="$1" + local py_cmd="${PYTHON_BIN:-python3}" + + "$py_cmd" - "$log_file" <<'PY' +import re +import sys +from pathlib import Path + +log_path = Path(sys.argv[1]) +text = log_path.read_text(errors="ignore") +match = re.search(r"ModuleNotFoundError:\s+No module named ['\"]([^'\"]+)['\"]", text) +if match: + print(match.group(1)) +PY +} + ensure_ota_venv() { local python_cmd="${PYTHON_BIN:-python3}" @@ -57,7 +77,7 @@ ensure_ota_venv() { echo "Installing OTA toolchain dependencies in venv..." pip install --upgrade pip setuptools wheel >/dev/null - pip install --upgrade platformio littlefs-python fatfsgen >/dev/null + pip install --upgrade platformio littlefs-python fatfs-ng pyyaml >/dev/null if ! command -v pio >/dev/null 2>&1; then echo "Error: 'pio' is not available in $VENV_DIR after install." @@ -85,7 +105,7 @@ run_pio_with_auto_deps() { fi local missing_module - missing_module=$(sed -n "s/.*ModuleNotFoundError: No module named '\([^']\+\)'.*/\1/p" "$log_file" | head -n 1) + missing_module=$(extract_missing_module "$log_file") if [[ -z "$missing_module" ]]; then echo "PlatformIO failed, but no missing Python module could be detected." From c127c2431e89586efa04c1157255bd35db68073b Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 16:59:03 +0200 Subject: [PATCH 011/125] Fix OTA fs mode detection and add async webserver deps --- platformio.ini | 3 ++- scripts/elegantota_upload.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index aa1f0ef..853e140 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,8 @@ lib_deps = ; https://github.com/denyssene/SimpleKalmanFilter https://bitbucket.org/David_Such/nexgen_filter.git#1.0.2 ayushsharma82/WebSerial - ; ESP Async WebServer + ESP32Async/AsyncTCP + ESP32Async/ESPAsyncWebServer https://github.com/iakop/LiquidCrystal_I2C_ESP32 https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- extra_scripts = pre:extra_scripts.py diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py index f24659f..b4814a9 100644 --- a/scripts/elegantota_upload.py +++ b/scripts/elegantota_upload.py @@ -89,7 +89,7 @@ def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signatu firmware_md5 = hashlib.md5(firmware_data).hexdigest() filename = os.path.basename(firmware_path) - mode = "fs" if filename == "spiffs.bin" else "fr" + mode = "fs" if filename in ("spiffs.bin", "littlefs.bin") else "fr" start_url = f"{base_url}/ota/start?mode={mode}&hash={firmware_md5}" upload_url = f"{base_url}/ota/upload" From 9b41f3157bfc991effc02af5f0c6c797dd072e64 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 17:05:15 +0200 Subject: [PATCH 012/125] Split OTA steps and enable library dependency finder --- README.md | 2 +- ota_update_all.sh | 12 ++++++++---- platformio.ini | 1 + 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a99e49c..d9e308e 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ For a **single-command OTA update of the whole project** (frontend files + firmw ./ota_update_all.sh s3-mini ``` -This builds `miniweb`, uploads LittleFS (`uploadfs`), then uploads firmware (`upload`) over ElegantOTA in one fell swoop. The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfs-ng`, `pyyaml`), and auto-retries if PlatformIO reports missing Python modules. +This builds `miniweb`, then runs OTA in two explicit steps: (1) upload LittleFS (`buildfs` + `uploadfs`) and (2) upload firmware (`upload`). The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfs-ng`, `pyyaml`), and auto-retries if PlatformIO reports missing Python modules. ## Latest features diff --git a/ota_update_all.sh b/ota_update_all.sh index 691bbc7..64b69ba 100755 --- a/ota_update_all.sh +++ b/ota_update_all.sh @@ -88,14 +88,15 @@ ensure_ota_venv() { run_pio_with_auto_deps() { local max_attempts=5 local attempt=1 + local pio_args=("$@") while ((attempt <= max_attempts)); do local log_file log_file=$(mktemp) - echo "PlatformIO attempt $attempt/$max_attempts..." + echo "PlatformIO attempt $attempt/$max_attempts: pio run ${pio_args[*]}" set +e - pio run -e "$PIO_ENV" -t buildfs -t uploadfs -t upload 2>&1 | tee "$log_file" + pio run "${pio_args[@]}" 2>&1 | tee "$log_file" local status=${PIPESTATUS[0]} set -e @@ -137,7 +138,10 @@ npm install --no-audit --no-fund npm run build popd >/dev/null -echo "Uploading filesystem + firmware via OTA (single run)..." -run_pio_with_auto_deps +echo "Step 1/2: Uploading LittleFS image via OTA..." +run_pio_with_auto_deps -e "$PIO_ENV" -t buildfs -t uploadfs + +echo "Step 2/2: Uploading firmware via OTA..." +run_pio_with_auto_deps -e "$PIO_ENV" -t upload echo "OTA update complete (web assets + firmware)." diff --git a/platformio.ini b/platformio.ini index 853e140..6cfa817 100644 --- a/platformio.ini +++ b/platformio.ini @@ -27,6 +27,7 @@ build_flags = -D ELEGANTOTA_USE_ASYNC_WEBSERVER=1 ; -D ARDUINO_USB_CDC_ON_BOOT=1 lib_compat_mode = strict +lib_ldf_mode = chain+ lib_deps = ayushsharma82/ElegantOTA ArduinoJson From ddb7c2996f35af7e66a5c3f1d0121fcebec4bab0 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 17:10:02 +0200 Subject: [PATCH 013/125] Add Network framework library dependency for OTA builds --- platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/platformio.ini b/platformio.ini index 6cfa817..1722eff 100644 --- a/platformio.ini +++ b/platformio.ini @@ -36,6 +36,7 @@ lib_deps = SPI https://github.com/adafruit/Adafruit_BusIO Wire + Network ; https://github.com/denyssene/SimpleKalmanFilter https://bitbucket.org/David_Such/nexgen_filter.git#1.0.2 ayushsharma82/WebSerial From 784b5b9a3fa29109799f557422d1a6f298d74362 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 17:17:02 +0200 Subject: [PATCH 014/125] Fix OTA build by removing invalid Network lib_deps entry --- platformio.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 1722eff..4b131d7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -36,7 +36,10 @@ lib_deps = SPI https://github.com/adafruit/Adafruit_BusIO Wire - Network + ; NOTE: Do not list `Network` here. It's a built-in Arduino-ESP32 framework + ; library dependency of WiFi/AsyncTCP, not a PlatformIO registry package. + ; Adding it to lib_deps makes PlatformIO try (and fail) to install a + ; third-party package named "Network", which can break include resolution. ; https://github.com/denyssene/SimpleKalmanFilter https://bitbucket.org/David_Such/nexgen_filter.git#1.0.2 ayushsharma82/WebSerial From cb0130536887ed07247dbb1a534665fe9dbe0c48 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 17:21:54 +0200 Subject: [PATCH 015/125] Adjust PlatformIO LDF settings to resolve ESP32 Network headers --- platformio.ini | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 4b131d7..2d29f78 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,8 +26,11 @@ build_flags = -D CORE_DEBUG_LEVEL=3 -D ELEGANTOTA_USE_ASYNC_WEBSERVER=1 ; -D ARDUINO_USB_CDC_ON_BOOT=1 -lib_compat_mode = strict -lib_ldf_mode = chain+ +; ESP32 Arduino core 3.3.x moved common network classes to the Network library. +; With strict+chain+ the dependency finder can miss framework-internal transitive +; headers (Network.h / NetworkInterface.h) when compiling AsyncTCP/WiFi. +lib_compat_mode = soft +lib_ldf_mode = deep+ lib_deps = ayushsharma82/ElegantOTA ArduinoJson From 386cf061899442acf126ec60508b6baaea752deb Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 17:28:59 +0200 Subject: [PATCH 016/125] Use non-plus LDF mode to restore ESP32 Network header resolution --- platformio.ini | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/platformio.ini b/platformio.ini index 2d29f78..7cc669a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,11 +26,12 @@ build_flags = -D CORE_DEBUG_LEVEL=3 -D ELEGANTOTA_USE_ASYNC_WEBSERVER=1 ; -D ARDUINO_USB_CDC_ON_BOOT=1 -; ESP32 Arduino core 3.3.x moved common network classes to the Network library. -; With strict+chain+ the dependency finder can miss framework-internal transitive -; headers (Network.h / NetworkInterface.h) when compiling AsyncTCP/WiFi. -lib_compat_mode = soft -lib_ldf_mode = deep+ +; NOTE: Avoid `chain+` / `deep+` with Arduino-ESP32 3.x. +; Those modes can drop framework-internal Network headers from the include path +; (see Network.h / NetworkInterface.h failures in WiFi + AsyncTCP builds). +; Non-`+` modes keep the build stable for this project. +lib_compat_mode = strict +lib_ldf_mode = chain lib_deps = ayushsharma82/ElegantOTA ArduinoJson From 5ca27049c5b3f411980f5a9f47e672d796144ae5 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 17:36:00 +0200 Subject: [PATCH 017/125] Improve Wi-Fi and websocket resilience --- src/CommandLoop.cpp | 33 ++++++++++++++++++++++++++++++--- src/CommandLoop.h | 1 + src/main.cpp | 2 ++ src/wifi_setup.cpp | 23 +++++++++++++++++++++++ src/wifi_setup.h | 1 + 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 0ec45d8..c34d4ee 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -10,6 +10,11 @@ Preferences preferences; +namespace { +constexpr unsigned long WEB_CLIENT_GRACE_PERIOD_MS = 10000; +unsigned long lastWebClientDisconnectMs = 0; +bool webClientGraceActive = false; +} void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { @@ -17,14 +22,15 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, switch (type) { case WS_EVT_CONNECT: logf("[%u] Connected!\n", client->id()); + webClientGraceActive = false; + lastWebClientDisconnectMs = 0; // client->text("Connected"); break; case WS_EVT_DISCONNECT: { logf("[%u] Disconnected!\n", client->id()); - // turn off heater and set fan to 100% - setHeaterPower(0); - setFanSpeed(preferences.getLong("coolFanSpeed", 65)); + webClientGraceActive = true; + lastWebClientDisconnectMs = millis(); } break; case WS_EVT_DATA: { @@ -157,3 +163,24 @@ void setupMainLoop(AsyncWebSocket *ws) { preferences.begin("preferences"); ws->onEvent(onWsEvent); } + +void updateConnectionSafety(AsyncWebSocket *ws) { + if (ws->count() > 0) { + webClientGraceActive = false; + return; + } + + if (!webClientGraceActive) { + return; + } + + if (millis() - lastWebClientDisconnectMs < WEB_CLIENT_GRACE_PERIOD_MS) { + return; + } + + // turn off heater and set fan to configured cooldown only after grace period + setHeaterPower(0); + setFanSpeed(preferences.getLong("coolFanSpeed", 65)); + webClientGraceActive = false; + log("No websocket clients after grace period, entering cooldown safety mode"); +} diff --git a/src/CommandLoop.h b/src/CommandLoop.h index 3c934ef..e580556 100644 --- a/src/CommandLoop.h +++ b/src/CommandLoop.h @@ -2,3 +2,4 @@ void setupMainLoop(AsyncWebSocket *ws); +void updateConnectionSafety(AsyncWebSocket *ws); diff --git a/src/main.cpp b/src/main.cpp index 1d44057..82feca2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -109,7 +109,9 @@ void setup(void) { void loop(void) { ElegantOTA.loop(); + maintainWifiConnection(); ws.cleanupClients(); + updateConnectionSafety(&ws); delay(10); takeReadings(); updateHeater(); diff --git a/src/wifi_setup.cpp b/src/wifi_setup.cpp index 5ccf8f2..218e764 100644 --- a/src/wifi_setup.cpp +++ b/src/wifi_setup.cpp @@ -27,6 +27,8 @@ class WiFiParams { WiFiParams params; const char *yaegerHostname = "yaeger.local"; +unsigned long lastReconnectAttemptMs = 0; +constexpr unsigned long WIFI_RECONNECT_INTERVAL_MS = 5000; void setupAP() { WiFi.mode(WIFI_AP); @@ -144,3 +146,24 @@ String getActiveIP() { } String getConfiguredHostname() { return String(yaegerHostname); } + +void maintainWifiConnection() { + wifi_mode_t mode = WiFi.getMode(); + if (mode != WIFI_MODE_STA && mode != WIFI_MODE_APSTA) { + return; + } + + wl_status_t status = WiFi.status(); + if (status == WL_CONNECTED || status == WL_IDLE_STATUS) { + return; + } + + unsigned long now = millis(); + if (now - lastReconnectAttemptMs < WIFI_RECONNECT_INTERVAL_MS) { + return; + } + + lastReconnectAttemptMs = now; + logf("WiFi not connected (status=%d), attempting reconnect", status); + WiFi.reconnect(); +} diff --git a/src/wifi_setup.h b/src/wifi_setup.h index 6d18aa9..0c6e397 100644 --- a/src/wifi_setup.h +++ b/src/wifi_setup.h @@ -7,6 +7,7 @@ // check if can connect // if not, start AP mode void setupWifi(); +void maintainWifiConnection(); extern const char *wifiPrefsKey; extern const char *wifiSSIDKey; From f33029a1fdb6bfb0ff2efb1441debf131aa61364 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 18:13:48 +0200 Subject: [PATCH 018/125] docs: add 2026 codebase analysis and improvement roadmap --- docs/code_analysis_2026-04-06.md | 155 +++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 docs/code_analysis_2026-04-06.md diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md new file mode 100644 index 0000000..ca226e7 --- /dev/null +++ b/docs/code_analysis_2026-04-06.md @@ -0,0 +1,155 @@ +# Yaeger codebase analysis (April 6, 2026) + +This is a high-level technical review of the firmware + web clients, with prioritized, up-to-date improvement proposals. + +## Scope reviewed + +- Firmware entrypoint and runtime loop (`src/main.cpp`) +- WebSocket command/control path (`src/CommandLoop.cpp`) +- REST API and Wi‑Fi credential flow (`src/api.cpp`, `src/wifi_setup.cpp`) +- Frontend app state, transport, and build setup (`miniweb`, `webserver`) +- Dependency freshness and security posture from package manager checks + +## Current strengths + +- Clear separation between firmware concerns: sensors, fan/heater control, API, and Wi‑Fi modules. +- Safety fallback exists when WebSocket clients disconnect (`updateConnectionSafety`) with cooldown fan behavior. +- OTA update path is already integrated with ElegantOTA and static content serving from LittleFS. +- A newer `miniweb` app exists (TypeScript + Vite) in parallel to legacy Svelte/Rollup webserver. + +## Key findings and prioritized improvements + +## 1) **Critical security hardening (do first)** + +### Findings + +- Wi‑Fi credentials are accepted over **HTTP GET query params** at `/api/wifi` (`ssid` / `pass`). +- API endpoints and OTA endpoint appear unauthenticated by default. +- Device exposes AP fallback mode and local admin surface; this is convenient but high-risk without auth. + +### Recommendations (2026 best-practice) + +1. Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings. +2. Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum: + - per-device admin password or token stored in NVS, + - CSRF-resistant flow for browser UI. +3. Protect OTA route with credentials and rate-limiting/backoff. +4. Add secure defaults in AP mode: + - WPA2/WPA3 AP passphrase (not open AP), + - setup-mode timeout window. + +## 2) **WebSocket robustness and heap stability (high priority)** + +### Findings + +- `deserializeJson` return value is not checked before consuming fields. +- Incoming frame handling concatenates payload into `String` and uses small fixed JSON capacity assumptions. +- Outgoing buffer is fixed `char buffer[200]`, risking truncation if payload grows. + +### Recommendations + +1. Validate parse result (`DeserializationError`) and reject malformed frames. +2. Enforce command schema validation (required fields, ranges). +3. Replace fixed-size `char[200]` with `measureJson` + dynamic/streamed response. +4. Add clamp logic for actuator values (e.g., fan/heater range validation) server-side regardless of client behavior. + +## 3) **Network resiliency and boot behavior (high priority)** + +### Findings + +- Wi‑Fi connect routine blocks in a loop up to ~10s with `delay(1000)` retries. +- Main loop includes regular delays and mixed timing responsibilities. + +### Recommendations + +1. Convert Wi‑Fi connect to non-blocking state machine (or bounded async retry steps). +2. Keep loop tick deterministic by moving periodic tasks to elapsed-time scheduling. +3. Add watchdog-friendly design: avoid long blocking sections in startup/connect paths. + +## 4) **Frontend modernization path (high priority, medium effort)** + +### Findings + +- Repository contains **two web UIs** (`webserver` legacy Svelte 3 + Rollup, and `miniweb` TypeScript + Vite). +- Legacy webserver dependency tree is significantly behind and has known advisory exposure via old Svelte line. + +### Recommendations + +1. Make `miniweb` the single primary frontend and define deprecation timeline for `webserver`. +2. If legacy UI must remain, plan migration to modern Svelte/Vite stack. +3. Standardize package manager/lockfile strategy (npm vs yarn) to reduce CI drift. + +## 5) **Dependency and supply-chain updates (high priority)** + +### Findings from `npm outdated` + +- `miniweb` has major updates pending (e.g., Vite 8.x, TypeScript 6.x, vite-plugin-pwa 1.x). +- `webserver` is heavily behind (Rollup 4.x, Svelte 5.x, SMUI 8.x available). +- root dependency `chartjs-plugin-trendline` also behind. + +### Findings from `npm audit` + +- `webserver` reports moderate vulnerabilities tied to old `svelte` line; major upgrade path available. + +### Recommendations + +1. Upgrade actively maintained UI (`miniweb`) first, one major at a time with CI snapshots. +2. Treat legacy `webserver` as frozen/deprecated or perform full migration sprint. +3. Add automated dependency checks (scheduled CI + Dependabot/Renovate). + +## 6) **API design and transport hygiene (medium priority)** + +### Findings + +- Control and data are mixed in loosely-typed WebSocket payloads. +- REST info endpoint is useful but minimal; no health/version compatibility contract. + +### Recommendations + +1. Version the protocol (`apiVersion`) across REST + WebSocket. +2. Introduce structured command envelopes and explicit error responses. +3. Add heartbeat/ping and reconnect backoff in frontend WebSocket client. + +## 7) **Build/test quality gates (medium priority)** + +### Findings + +- Frontend builds succeed, but there is no obvious unified CI matrix in repo root. +- Firmware static checks are configured in PlatformIO config but not validated in this environment (`pio` unavailable). + +### Recommendations + +1. Add CI pipeline matrix: + - firmware static analysis/build, + - miniweb build/lint/typecheck, + - optional legacy webserver build until sunset. +2. Add pre-merge checks for formatting + basic unit tests for pure logic modules. +3. Add release artifact version stamping for firmware + frontend and compatibility check. + +## Proposed implementation roadmap + +### Phase 1 (1-2 weeks): security + reliability + +- Migrate `/api/wifi` to authenticated POST. +- Add OTA auth + AP hardening defaults. +- Add WebSocket parse/validation/clamp guards. + +### Phase 2 (1-2 weeks): frontend consolidation + +- Define `miniweb` as primary. +- Freeze or retire `webserver`; remove dual-maintenance overhead. +- Upgrade `miniweb` core tooling with compatibility tests. + +### Phase 3 (ongoing): CI and observability + +- Introduce CI matrix and scheduled dependency scanning. +- Add structured logs + fault counters exposed via `/api/info` (or `/api/health`). +- Add smoke tests for profile run and actuator safety constraints. + +## Commands run for this analysis + +- `npm outdated --json` (repo root, `miniweb`, `webserver`) +- `npm run build` (`miniweb`, `webserver`) +- `npm audit --omit=dev --json` (`webserver`) +- `pio --version` (tool unavailable in environment) + From 241ea927f3ed981be847d7e3f806cfab196c49ee Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 18:23:19 +0200 Subject: [PATCH 019/125] Harden wifi/OTA auth and update security TODO status --- docs/code_analysis_2026-04-06.md | 38 +++++++++++++----- src/api.cpp | 66 +++++++++++++++++++++++--------- src/main.cpp | 4 +- src/security.cpp | 48 +++++++++++++++++++++++ src/security.h | 12 ++++++ src/wifi_setup.cpp | 14 ++++++- 6 files changed, 152 insertions(+), 30 deletions(-) create mode 100644 src/security.cpp create mode 100644 src/security.h diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md index ca226e7..f5a54e6 100644 --- a/docs/code_analysis_2026-04-06.md +++ b/docs/code_analysis_2026-04-06.md @@ -21,22 +21,42 @@ This is a high-level technical review of the firmware + web clients, with priori ## 1) **Critical security hardening (do first)** +### Implementation status (April 6, 2026 update) + +- ~~Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings.~~ ✅ Implemented (`/api/wifi` now requires `POST` JSON). +- ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ 🔄 Partially implemented. + - ~~per-device admin password or token stored in NVS,~~ ✅ Implemented for `/api/wifi` and OTA Basic Auth. + - CSRF-resistant flow for browser UI. ⏳ Pending. +- ~~Protect OTA route with credentials and rate-limiting/backoff.~~ 🔄 Credentials implemented; rate-limiting/backoff pending. +- ~~Add secure defaults in AP mode:~~ 🔄 Partially implemented. + - ~~WPA2/WPA3 AP passphrase (not open AP),~~ ✅ Implemented (password-protected AP). + - ~~setup-mode timeout window.~~ ✅ Implemented (AP setup timeout + restart). + +### Updated TODO list + +- [x] Replace `/api/wifi` GET query credential flow with authenticated `POST` + JSON body. +- [x] Protect OTA with admin credentials. +- [x] Enable AP passphrase and setup timeout window. +- [ ] Add auth gate for WebSocket mutable control commands. +- [ ] Add CSRF-resistant browser flow for authenticated actions. +- [ ] Add rate limiting / exponential backoff for OTA and mutable control endpoints. + ### Findings -- Wi‑Fi credentials are accepted over **HTTP GET query params** at `/api/wifi` (`ssid` / `pass`). -- API endpoints and OTA endpoint appear unauthenticated by default. -- Device exposes AP fallback mode and local admin surface; this is convenient but high-risk without auth. +- ~~Wi‑Fi credentials are accepted over **HTTP GET query params** at `/api/wifi` (`ssid` / `pass`).~~ +- ~~API endpoints and OTA endpoint appear unauthenticated by default.~~ +- Device exposes AP fallback mode and local admin surface; this remains high-risk until WebSocket auth + CSRF protections are added. ### Recommendations (2026 best-practice) -1. Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings. -2. Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum: - - per-device admin password or token stored in NVS, +1. ~~Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings.~~ +2. ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ + - ~~per-device admin password or token stored in NVS,~~ - CSRF-resistant flow for browser UI. -3. Protect OTA route with credentials and rate-limiting/backoff. +3. ~~Protect OTA route with credentials~~ and add rate-limiting/backoff. 4. Add secure defaults in AP mode: - - WPA2/WPA3 AP passphrase (not open AP), - - setup-mode timeout window. + - ~~WPA2/WPA3 AP passphrase (not open AP),~~ + - ~~setup-mode timeout window.~~ ## 2) **WebSocket robustness and heap stability (high priority)** diff --git a/src/api.cpp b/src/api.cpp index 3807a90..a84182b 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -1,6 +1,7 @@ #include "api.h" #include "logging.h" +#include "security.h" #include "version.h" #include "wifi_setup.h" #include @@ -9,25 +10,52 @@ void setupApi(AsyncWebServer *server) { log("setting up api"); - server->on("/api/wifi", HTTP_GET, [](AsyncWebServerRequest *request) { - if (!request->hasParam("ssid") || !request->hasParam("pass")) { - AsyncWebServerResponse *response = request->beginResponse(400); - request->send(response); - return; - } - - const char *ssid = request->getParam("ssid")->value().c_str(); - const char *pass = request->getParam("pass")->value().c_str(); - - Preferences prefs; - prefs.begin(wifiPrefsKey, false); - prefs.putString(wifiSSIDKey, ssid); - prefs.putString(wifiPassKey, pass); - logf("saving to prefs, ssid: %s", ssid); - - prefs.end(); - request->send(200); - }); + + server->on( + "/api/wifi", HTTP_POST, + [](AsyncWebServerRequest *request) { + // handled in body parser + }, + NULL, + [](AsyncWebServerRequest *request, uint8_t *data, size_t len, + size_t index, size_t total) { + if (index != 0 || len != total) { + request->send(400, "application/json", + "{\"error\":\"chunked body not supported\"}"); + return; + } + + if (!isAuthorizedRequest(request)) { + return; + } + + DynamicJsonDocument doc(256); + DeserializationError err = deserializeJson(doc, data, len); + if (err) { + request->send(400, "application/json", + "{\"error\":\"invalid json\"}"); + return; + } + + const char *ssid = doc["ssid"] | ""; + const char *pass = doc["pass"] | ""; + + if (strlen(ssid) == 0 || strlen(pass) < 8) { + request->send( + 400, "application/json", + "{\"error\":\"ssid required and pass must be >=8 chars\"}"); + return; + } + + Preferences prefs; + prefs.begin(wifiPrefsKey, false); + prefs.putString(wifiSSIDKey, ssid); + prefs.putString(wifiPassKey, pass); + prefs.end(); + + logf("saved wifi ssid to prefs: %s", ssid); + request->send(200, "application/json", "{\"ok\":true}"); + }); server->on("/api/info", HTTP_GET, [](AsyncWebServerRequest *request) { StaticJsonDocument<256> doc; diff --git a/src/main.cpp b/src/main.cpp index 82feca2..9ba9004 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,6 +17,7 @@ #include "heater.h" #include "logging.h" #include "sensors.h" +#include "security.h" #include "wifi_setup.h" #define PIN 48 @@ -82,7 +83,8 @@ void setup(void) { server.serveStatic("/settings", LittleFS, "/").setDefaultFile("index.html"); server.serveStatic("/editor", LittleFS, "/").setDefaultFile("index.html"); - ElegantOTA.begin(&server); // Start ElegantOTA + String adminSecret = getApiAdminSecret(); + ElegantOTA.begin(&server, getApiAdminUsername(), adminSecret.c_str()); // Start ElegantOTA // ElegantOTA callbacks ElegantOTA.onStart(onOTAStart); ElegantOTA.onProgress(onOTAProgress); diff --git a/src/security.cpp b/src/security.cpp new file mode 100644 index 0000000..7a99fc9 --- /dev/null +++ b/src/security.cpp @@ -0,0 +1,48 @@ +#include "security.h" + +#include "wifi_setup.h" +#include + +namespace { +constexpr const char *kAdminUser = "admin"; +constexpr const char *kSecretPrefsNamespace = "security"; +constexpr const char *kSecretPrefsKey = "adminSecret"; +constexpr const char *kDefaultSecret = "ChangeMeYaeger!"; + +String loadOrCreateSecret() { + Preferences prefs; + prefs.begin(kSecretPrefsNamespace, false); + + String secret = prefs.getString(kSecretPrefsKey, ""); + if (secret.length() < 8) { + secret = String(kDefaultSecret); + prefs.putString(kSecretPrefsKey, secret); + } + + prefs.end(); + return secret; +} +} // namespace + +const char *getApiAdminUsername() { return kAdminUser; } + +String getApiAdminSecret() { return loadOrCreateSecret(); } + +String getApPassphrase() { + String secret = loadOrCreateSecret(); + if (secret.length() < 8) { + return String("yaeger-setup"); + } + + return secret; +} + +bool isAuthorizedRequest(AsyncWebServerRequest *request) { + String secret = loadOrCreateSecret(); + if (request->authenticate(kAdminUser, secret.c_str())) { + return true; + } + + request->requestAuthentication(); + return false; +} diff --git a/src/security.h b/src/security.h new file mode 100644 index 0000000..d68340c --- /dev/null +++ b/src/security.h @@ -0,0 +1,12 @@ +#ifndef SECURITY_H +#define SECURITY_H + +#include +#include + +const char *getApiAdminUsername(); +String getApiAdminSecret(); +String getApPassphrase(); +bool isAuthorizedRequest(AsyncWebServerRequest *request); + +#endif diff --git a/src/wifi_setup.cpp b/src/wifi_setup.cpp index 218e764..84d0700 100644 --- a/src/wifi_setup.cpp +++ b/src/wifi_setup.cpp @@ -1,6 +1,7 @@ #include "WiFiType.h" #include "esp32-hal.h" #include "logging.h" +#include "security.h" #include #include #include @@ -29,12 +30,17 @@ WiFiParams params; const char *yaegerHostname = "yaeger.local"; unsigned long lastReconnectAttemptMs = 0; constexpr unsigned long WIFI_RECONNECT_INTERVAL_MS = 5000; +constexpr unsigned long AP_SETUP_TIMEOUT_MS = 15UL * 60UL * 1000UL; +unsigned long apModeStartMs = 0; void setupAP() { WiFi.mode(WIFI_AP); delay(100); - WiFi.softAP("Yaeger"); + String apPassphrase = getApPassphrase(); + WiFi.softAP("Yaeger", apPassphrase.c_str()); WiFi.setTxPower(WIFI_POWER_8_5dBm); + apModeStartMs = millis(); + log("AP setup mode enabled with WPA2 passphrase"); } void connectToWifi() { @@ -149,6 +155,12 @@ String getConfiguredHostname() { return String(yaegerHostname); } void maintainWifiConnection() { wifi_mode_t mode = WiFi.getMode(); + if (mode == WIFI_MODE_AP && apModeStartMs > 0 && millis() - apModeStartMs > AP_SETUP_TIMEOUT_MS) { + log("AP setup window expired, restarting device"); + ESP.restart(); + return; + } + if (mode != WIFI_MODE_STA && mode != WIFI_MODE_APSTA) { return; } From 3d103e8fabd01248e6e2275eabcc3efbd2fbe51b Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 18:33:59 +0200 Subject: [PATCH 020/125] Complete point 1 hardening with WS auth, CSRF, and throttling --- docs/code_analysis_2026-04-06.md | 14 ++-- miniweb/src/auth.ts | 20 ++++++ miniweb/src/main.ts | 16 ++++- miniweb/src/roast.ts | 4 +- src/CommandLoop.cpp | 111 +++++++++++++++++++------------ src/api.cpp | 22 +++++- src/security.cpp | 51 +++++++++++++- src/security.h | 4 ++ 8 files changed, 185 insertions(+), 57 deletions(-) create mode 100644 miniweb/src/auth.ts diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md index f5a54e6..8586fd7 100644 --- a/docs/code_analysis_2026-04-06.md +++ b/docs/code_analysis_2026-04-06.md @@ -26,8 +26,8 @@ This is a high-level technical review of the firmware + web clients, with priori - ~~Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings.~~ ✅ Implemented (`/api/wifi` now requires `POST` JSON). - ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ 🔄 Partially implemented. - ~~per-device admin password or token stored in NVS,~~ ✅ Implemented for `/api/wifi` and OTA Basic Auth. - - CSRF-resistant flow for browser UI. ⏳ Pending. -- ~~Protect OTA route with credentials and rate-limiting/backoff.~~ 🔄 Credentials implemented; rate-limiting/backoff pending. + - ~~CSRF-resistant flow for browser UI.~~ ✅ Implemented (`X-Yaeger-CSRF` header validated for mutable REST writes). +- ~~Protect OTA route with credentials~~ and rate-limiting/backoff. 🔄 Credentials done; OTA backoff/rate-limiting still pending. - ~~Add secure defaults in AP mode:~~ 🔄 Partially implemented. - ~~WPA2/WPA3 AP passphrase (not open AP),~~ ✅ Implemented (password-protected AP). - ~~setup-mode timeout window.~~ ✅ Implemented (AP setup timeout + restart). @@ -37,22 +37,22 @@ This is a high-level technical review of the firmware + web clients, with priori - [x] Replace `/api/wifi` GET query credential flow with authenticated `POST` + JSON body. - [x] Protect OTA with admin credentials. - [x] Enable AP passphrase and setup timeout window. -- [ ] Add auth gate for WebSocket mutable control commands. -- [ ] Add CSRF-resistant browser flow for authenticated actions. -- [ ] Add rate limiting / exponential backoff for OTA and mutable control endpoints. +- [x] Add auth gate for WebSocket mutable control commands. +- [x] Add CSRF-resistant browser flow for authenticated actions. +- [ ] Add rate limiting / exponential backoff for OTA endpoint (REST/WS mutable endpoint throttling implemented). ### Findings - ~~Wi‑Fi credentials are accepted over **HTTP GET query params** at `/api/wifi` (`ssid` / `pass`).~~ - ~~API endpoints and OTA endpoint appear unauthenticated by default.~~ -- Device exposes AP fallback mode and local admin surface; this remains high-risk until WebSocket auth + CSRF protections are added. +- Device exposes AP fallback mode and local admin surface; risk is now reduced with auth + CSRF controls, with OTA-specific backoff/rate limiting still pending. ### Recommendations (2026 best-practice) 1. ~~Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings.~~ 2. ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ - ~~per-device admin password or token stored in NVS,~~ - - CSRF-resistant flow for browser UI. + - ~~CSRF-resistant flow for browser UI.~~ 3. ~~Protect OTA route with credentials~~ and add rate-limiting/backoff. 4. Add secure defaults in AP mode: - ~~WPA2/WPA3 AP passphrase (not open AP),~~ diff --git a/miniweb/src/auth.ts b/miniweb/src/auth.ts new file mode 100644 index 0000000..5c89ed8 --- /dev/null +++ b/miniweb/src/auth.ts @@ -0,0 +1,20 @@ +const AUTH_STORAGE_KEY = "yaegerAdminSecret"; + +export function getAdminSecret(): string { + const existing = localStorage.getItem(AUTH_STORAGE_KEY); + if (existing && existing.length >= 8) { + return existing; + } + + const value = window.prompt("Enter Yaeger admin password", "") || ""; + if (value.length >= 8) { + localStorage.setItem(AUTH_STORAGE_KEY, value); + } + + return value; +} + +export function getBasicAuthHeaderValue(): string { + const secret = getAdminSecret(); + return `Basic ${btoa(`admin:${secret}`)}`; +} diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index bb62a3e..71cb812 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -4,6 +4,7 @@ import { roastApp } from "./roast"; import { profile, ProfileControl } from "./profiling.ts"; import { PIDController } from "./pid.ts"; import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; +import { getBasicAuthHeaderValue } from "./auth"; declare const __APP_VERSION__: string; declare const __BUILD_TIMESTAMP__: string; @@ -14,6 +15,7 @@ interface DeviceInfo { ssid: string; ip: string; hostname: string; + csrfToken?: string; } const { button, div, input, p, span, h1, h2 } = van.tags; @@ -30,6 +32,7 @@ const passField = van.state(""); // Versioning and network details const deviceInfo = van.state(null); const deviceInfoError = van.state(null); +const csrfToken = van.state(""); const appVersion = __APP_VERSION__; const buildTimestamp = new Date(__BUILD_TIMESTAMP__).toLocaleString(); @@ -43,6 +46,7 @@ const refreshDeviceInfo = async () => { } deviceInfo.val = (await response.json()) as DeviceInfo; + csrfToken.val = deviceInfo.val.csrfToken || ""; } catch (error: unknown) { deviceInfo.val = null; if (error instanceof Error) { @@ -60,9 +64,15 @@ const updateWifiSettings = async () => { const pass = passField.val; try { - const response = await fetch( - `http://${location.host}/api/wifi?ssid=${encodeURIComponent(ssid)}&pass=${encodeURIComponent(pass)}`, - ); + const response = await fetch(`http://${location.host}/api/wifi`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": csrfToken.val, + }, + body: JSON.stringify({ ssid, pass }), + }); if (response.ok) { alert( "Wifi settings updated!\nPlease restart for the new settings to take effect", diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index 0e52dff..453d3e3 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -18,6 +18,7 @@ import { ProfileControl, } from "./profiling.ts"; import { socket, lastMessage, lastUpdate } from "./websocket"; +import { getAdminSecret } from "./auth"; const { label, button, div, input, select, option, canvas, p, span } = van.tags; @@ -225,7 +226,8 @@ function appendEvent(label: string) { } function sendCommand(data: any) { - let msg = JSON.stringify(data); + const authToken = getAdminSecret(); + const msg = JSON.stringify({ ...data, authToken }); console.log("sending command: ", msg); socket?.send(msg); } diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index c34d4ee..69b4aa8 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -1,20 +1,51 @@ #include "fan.h" #include "heater.h" #include "logging.h" +#include "security.h" #include "sensors.h" #include #include +#include #include #include -#include Preferences preferences; namespace { constexpr unsigned long WEB_CLIENT_GRACE_PERIOD_MS = 10000; +constexpr unsigned long MUTATING_CMD_MIN_INTERVAL_MS = 100; unsigned long lastWebClientDisconnectMs = 0; +unsigned long lastMutatingCommandMs = 0; bool webClientGraceActive = false; + +bool isMutatingCommand(const char *command) { + if (command == NULL) { + return false; + } + + return strncmp(command, "setBurner", 9) == 0 || + strncmp(command, "setFan", 6) == 0 || + strncmp(command, "setPreferences", 14) == 0; +} + +bool enforceMutatingCommandAuth(AsyncWebSocketClient *client, + JsonDocument &doc) { + const char *authToken = doc["authToken"] | ""; + if (!isValidAdminToken(authToken)) { + client->text("{\"error\":\"unauthorized mutating command\"}"); + return false; + } + + unsigned long now = millis(); + if (now - lastMutatingCommandMs < MUTATING_CMD_MIN_INTERVAL_MS) { + client->text("{\"error\":\"rate limit exceeded\"}"); + return false; + } + + lastMutatingCommandMs = now; + return true; } +} // namespace void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) { @@ -24,8 +55,6 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, logf("[%u] Connected!\n", client->id()); webClientGraceActive = false; lastWebClientDisconnectMs = 0; - // client->text("Connected"); - break; case WS_EVT_DISCONNECT: { logf("[%u] Disconnected!\n", client->id()); @@ -41,9 +70,6 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, logf("final: %d\n", info->final); #endif String msg = ""; - /*if (info->opcode != WS_TEXT || !info->final) {*/ - /* break;*/ - /*}*/ for (size_t i = 0; i < info->len; i++) { msg += (char)data[i]; @@ -52,23 +78,29 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, logf("msg: %s\n", msg.c_str()); #endif - const size_t capacity = JSON_OBJECT_SIZE(3) + 60; // Memory pool + const size_t capacity = JSON_OBJECT_SIZE(4) + 160; DynamicJsonDocument doc(capacity); - // DEBUG WEBSOCKET - // logf("[%u] get Text: %s\n", num, payload); + DeserializationError err = deserializeJson(doc, msg); + if (err) { + client->text("{\"error\":\"invalid json\"}"); + return; + } - // Extract Values lt. https://arduinojson.org/v6/example/http-client/ - // Artisan Anleitung: https://artisan-scope.org/devices/websockets/ + long ln_id = doc["id"].as(); + const char *command = doc["command"].as(); + bool hasDirectMutatingFields = !doc["BurnerVal"].isNull() || !doc["FanVal"].isNull(); - deserializeJson(doc, msg); + if (hasDirectMutatingFields || isMutatingCommand(command)) { + if (!enforceMutatingCommandAuth(client, doc)) { + return; + } + } - long ln_id = doc["id"].as(); // Get BurnerVal from Artisan over Websocket if (!doc["BurnerVal"].isNull()) { long val = doc["BurnerVal"].as(); logf("BurnerVal: %d\n", val); - // DimmerVal = doc["BurnerVal"].as(); setHeaterPower(val); } if (!doc["FanVal"].isNull()) { @@ -77,8 +109,6 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, setFanSpeed(val); } - // Send Values to Artisan over Websocket - const char *command = doc["command"].as(); if (command != NULL && strncmp(command, "setBurner", 9) == 0) { long val = doc["value"].as(); logf("BurnerVal: %d\n", val); @@ -91,8 +121,8 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } // Safeguard to prevent heater fuse blowout - if (getHeaterPower() > 0 && getFanSpeed() <= 30) { - setFanSpeed(30); + if (getHeaterPower() > 0 && getFanSpeed() <= 30) { + setFanSpeed(30); } if (command != NULL && strncmp(command, "setPreferences", 14) == 0) { @@ -115,46 +145,40 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } } - if (command != NULL && (strncmp(command, "setPreferences", 14) == 0 || strncmp(command, "getPreferences", 14) == 0)) { + if (command != NULL && + (strncmp(command, "setPreferences", 14) == 0 || + strncmp(command, "getPreferences", 14) == 0)) { JsonObject root = doc.to(); - JsonObject data = root.createNestedObject("data"); + JsonObject dataObj = root.createNestedObject("data"); root["id"] = ln_id; - data["type"] = "preferences"; - data["pidKp"] = preferences.getDouble("pidKp", 1.0); - data["pidKi"] = preferences.getDouble("pidKi", 0.1); - data["pidKd"] = preferences.getDouble("pidKd", 0.01); - data["cooldownFanSpeed"] = preferences.getLong("coolFanSpeed", 65); + dataObj["type"] = "preferences"; + dataObj["pidKp"] = preferences.getDouble("pidKp", 1.0); + dataObj["pidKi"] = preferences.getDouble("pidKi", 0.1); + dataObj["pidKd"] = preferences.getDouble("pidKd", 0.01); + dataObj["cooldownFanSpeed"] = preferences.getLong("coolFanSpeed", 65); } if (command != NULL && strncmp(command, "getData", 7) == 0) { JsonObject root = doc.to(); - JsonObject data = root.createNestedObject("data"); + JsonObject dataObj = root.createNestedObject("data"); root["id"] = ln_id; float etbt[3]; getETBTReadings(etbt); - data["type"] = "status"; - data["ET"] = etbt[0]; // Med_ExhaustTemp.getMedian() - data["BT"] = etbt[1]; // Med_BeanTemp.getMedian(); - data["Amb"] = etbt[2]; - data["BurnerVal"] = getHeaterPower(); // float(DimmerVal); - data["FanVal"] = getFanSpeed(); + dataObj["type"] = "status"; + dataObj["ET"] = etbt[0]; + dataObj["BT"] = etbt[1]; + dataObj["Amb"] = etbt[2]; + dataObj["BurnerVal"] = getHeaterPower(); + dataObj["FanVal"] = getFanSpeed(); } - char buffer[200]; // create temp buffer - size_t len = serializeJson(doc, buffer); // serialize to buffer - // DEBUG WEBSOCKET + char buffer[240]; + serializeJson(doc, buffer); log(buffer); - client->text(buffer); - // send message to client - // webSocket.sendTXT(num, "message here"); - - // send data to all connected clients - // webSocket.broadcastTXT("message here"); } break; - default: // send message to client + default: logf("unhandled message type: %d\n", type); - // webSocket.sendBIN(num, payload, length); break; } } @@ -178,7 +202,6 @@ void updateConnectionSafety(AsyncWebSocket *ws) { return; } - // turn off heater and set fan to configured cooldown only after grace period setHeaterPower(0); setFanSpeed(preferences.getLong("coolFanSpeed", 65)); webClientGraceActive = false; diff --git a/src/api.cpp b/src/api.cpp index a84182b..0ae1bdf 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -8,6 +8,11 @@ #include #include +namespace { +unsigned long lastWifiMutationMs = 0; +constexpr unsigned long WIFI_MUTATION_MIN_INTERVAL_MS = 3000; +} + void setupApi(AsyncWebServer *server) { log("setting up api"); @@ -29,6 +34,19 @@ void setupApi(AsyncWebServer *server) { return; } + if (!hasValidCsrfHeader(request)) { + request->send(403, "application/json", + "{\"error\":\"missing/invalid csrf header\"}"); + return; + } + + unsigned long now = millis(); + if (now - lastWifiMutationMs < WIFI_MUTATION_MIN_INTERVAL_MS) { + request->send(429, "application/json", + "{\"error\":\"too many wifi updates\"}"); + return; + } + DynamicJsonDocument doc(256); DeserializationError err = deserializeJson(doc, data, len); if (err) { @@ -53,17 +71,19 @@ void setupApi(AsyncWebServer *server) { prefs.putString(wifiPassKey, pass); prefs.end(); + lastWifiMutationMs = now; logf("saved wifi ssid to prefs: %s", ssid); request->send(200, "application/json", "{\"ok\":true}"); }); server->on("/api/info", HTTP_GET, [](AsyncWebServerRequest *request) { - StaticJsonDocument<256> doc; + StaticJsonDocument<320> doc; doc["firmwareVersion"] = YAEGER_FW_VERSION; doc["networkMode"] = getWifiModeString(); doc["ssid"] = getActiveSSID(); doc["ip"] = getActiveIP(); doc["hostname"] = getConfiguredHostname(); + doc["csrfToken"] = getCsrfToken(); String body; serializeJson(doc, body); diff --git a/src/security.cpp b/src/security.cpp index 7a99fc9..5663cd7 100644 --- a/src/security.cpp +++ b/src/security.cpp @@ -1,13 +1,17 @@ #include "security.h" -#include "wifi_setup.h" #include +#include namespace { constexpr const char *kAdminUser = "admin"; constexpr const char *kSecretPrefsNamespace = "security"; constexpr const char *kSecretPrefsKey = "adminSecret"; constexpr const char *kDefaultSecret = "ChangeMeYaeger!"; +constexpr unsigned long kAuthBackoffMs = 5000; + +unsigned long authBlockedUntilMs = 0; +String csrfToken = ""; String loadOrCreateSecret() { Preferences prefs; @@ -22,6 +26,19 @@ String loadOrCreateSecret() { prefs.end(); return secret; } + +void ensureCsrfToken() { + if (csrfToken.length() > 0) { + return; + } + + uint32_t r1 = esp_random(); + uint32_t r2 = esp_random(); + char token[17] = {0}; + snprintf(token, sizeof(token), "%08lx%08lx", (unsigned long)r1, + (unsigned long)r2); + csrfToken = String(token); +} } // namespace const char *getApiAdminUsername() { return kAdminUser; } @@ -37,12 +54,44 @@ String getApPassphrase() { return secret; } +String getCsrfToken() { + ensureCsrfToken(); + return csrfToken; +} + bool isAuthorizedRequest(AsyncWebServerRequest *request) { + unsigned long now = millis(); + if (authBlockedUntilMs > now) { + request->send(429, "application/json", + "{\"error\":\"auth temporarily rate-limited\"}"); + return false; + } + String secret = loadOrCreateSecret(); if (request->authenticate(kAdminUser, secret.c_str())) { return true; } + authBlockedUntilMs = now + kAuthBackoffMs; request->requestAuthentication(); return false; } + +bool hasValidCsrfHeader(AsyncWebServerRequest *request) { + ensureCsrfToken(); + if (!request->hasHeader("X-Yaeger-CSRF")) { + return false; + } + + AsyncWebHeader *csrfHeader = request->getHeader("X-Yaeger-CSRF"); + return csrfHeader->value() == csrfToken; +} + +bool isValidAdminToken(const char *token) { + if (token == NULL) { + return false; + } + + String expected = loadOrCreateSecret(); + return expected.equals(token); +} diff --git a/src/security.h b/src/security.h index d68340c..999c289 100644 --- a/src/security.h +++ b/src/security.h @@ -7,6 +7,10 @@ const char *getApiAdminUsername(); String getApiAdminSecret(); String getApPassphrase(); +String getCsrfToken(); + bool isAuthorizedRequest(AsyncWebServerRequest *request); +bool hasValidCsrfHeader(AsyncWebServerRequest *request); +bool isValidAdminToken(const char *token); #endif From f90d54bcf1f9200711df45c61ebea14ceba19f21 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 18:39:33 +0200 Subject: [PATCH 021/125] Fix OTA upload script for authenticated ElegantOTA --- ota_update_all.sh | 14 ++++++++ scripts/elegantota_upload.py | 63 ++++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/ota_update_all.sh b/ota_update_all.sh index 64b69ba..dce7339 100755 --- a/ota_update_all.sh +++ b/ota_update_all.sh @@ -130,6 +130,20 @@ run_pio_with_auto_deps() { echo "Using OTA PlatformIO environment: $PIO_ENV" + +if [[ -z "${YAEGER_OTA_USERNAME:-}" ]]; then + export YAEGER_OTA_USERNAME="admin" +fi + +if [[ -z "${YAEGER_OTA_PASSWORD:-}" ]]; then + if [[ -t 0 ]]; then + read -r -s -p "Enter OTA admin password: " YAEGER_OTA_PASSWORD + echo + export YAEGER_OTA_PASSWORD + else + echo "Warning: YAEGER_OTA_PASSWORD is not set; OTA upload may fail with HTTP 401." + fi +fi ensure_ota_venv echo "Building miniweb assets..." diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py index b4814a9..ba8bd48 100644 --- a/scripts/elegantota_upload.py +++ b/scripts/elegantota_upload.py @@ -1,6 +1,10 @@ """ PlatformIO custom upload command for ElegantOTA (web OTA). +Supports HTTP Basic Auth via one of: + - YAEGER_OTA_USERNAME / YAEGER_OTA_PASSWORD environment variables + - Credentials embedded in custom_upload_url, e.g. http://user:pass@yaeger.local/update + Usage in platformio.ini: upload_protocol = custom custom_upload_url = http://yaeger.local/update @@ -9,6 +13,7 @@ from __future__ import annotations +import base64 import hashlib import mimetypes import os @@ -53,21 +58,33 @@ def _multipart(fields: dict[str, str], files: dict[str, tuple[str, bytes, str]]) return body, boundary -def _http_get(url: str) -> tuple[int, bytes]: +def _build_auth_header(username: str | None, password: str | None) -> str | None: + if not username or password is None: + return None + + token = base64.b64encode(f"{username}:{password}".encode()).decode() + return f"Basic {token}" + + +def _http_get(url: str, auth_header: str | None) -> tuple[int, bytes]: req = urllib.request.Request(url=url, method="GET") + if auth_header: + req.add_header("Authorization", auth_header) with urllib.request.urlopen(req, timeout=30) as response: return response.status, response.read() -def _http_post(url: str, body: bytes, content_type: str) -> tuple[int, bytes]: +def _http_post(url: str, body: bytes, content_type: str, auth_header: str | None) -> tuple[int, bytes]: req = urllib.request.Request(url=url, method="POST", data=body) req.add_header("Content-Type", content_type) req.add_header("Content-Length", str(len(body))) + if auth_header: + req.add_header("Authorization", auth_header) with urllib.request.urlopen(req, timeout=120) as response: return response.status, response.read() -def _normalise_base_url(custom_upload_url: str) -> str: +def _normalise_base_url(custom_upload_url: str) -> tuple[str, str | None]: parsed = urllib.parse.urlparse(custom_upload_url) if parsed.scheme not in ("http", "https"): raise ValueError(f"custom_upload_url must start with http:// or https://, got: {custom_upload_url}") @@ -76,13 +93,31 @@ def _normalise_base_url(custom_upload_url: str) -> str: if path.endswith("/update"): path = path[: -len("/update")] - return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")).rstrip("/") + base_url = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")).rstrip("/") + + env_username = os.getenv("YAEGER_OTA_USERNAME") + env_password = os.getenv("YAEGER_OTA_PASSWORD") + + url_username = urllib.parse.unquote(parsed.username) if parsed.username else None + url_password = urllib.parse.unquote(parsed.password) if parsed.password else None + + username = env_username or url_username or "admin" + password = env_password if env_password is not None else url_password + + # Remove credentials from URL that will be used in requests + if "@" in base_url: + sanitized_netloc = parsed.hostname or "" + if parsed.port: + sanitized_netloc = f"{sanitized_netloc}:{parsed.port}" + base_url = urllib.parse.urlunparse((parsed.scheme, sanitized_netloc, path, "", "", "")).rstrip("/") + + return base_url, _build_auth_header(username, password) def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signature) firmware_path = str(source[0]) custom_upload_url = env.GetProjectOption("custom_upload_url") - base_url = _normalise_base_url(custom_upload_url) + base_url, auth_header = _normalise_base_url(custom_upload_url) with open(firmware_path, "rb") as firmware_file: firmware_data = firmware_file.read() @@ -96,7 +131,14 @@ def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signatu print(f"ElegantOTA start: {start_url}") try: - status, _ = _http_get(start_url) + status, _ = _http_get(start_url, auth_header) + except urllib.error.HTTPError as exc: + if exc.code == 401: + raise RuntimeError( + "ElegantOTA requires authentication. Set YAEGER_OTA_PASSWORD " + "(and optional YAEGER_OTA_USERNAME) before running PlatformIO upload." + ) from exc + raise RuntimeError(f"Failed to reach ElegantOTA start endpoint: {exc}") from exc except urllib.error.URLError as exc: raise RuntimeError(f"Failed to reach ElegantOTA start endpoint: {exc}") from exc @@ -111,7 +153,14 @@ def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signatu print(f"ElegantOTA upload: {upload_url}") try: - status, response = _http_post(upload_url, body, f"multipart/form-data; boundary={boundary}") + status, response = _http_post(upload_url, body, f"multipart/form-data; boundary={boundary}", auth_header) + except urllib.error.HTTPError as exc: + if exc.code == 401: + raise RuntimeError( + "ElegantOTA upload unauthorized. Verify YAEGER_OTA_PASSWORD " + "matches the device admin password." + ) from exc + raise RuntimeError(f"Failed to upload to ElegantOTA endpoint: {exc}") from exc except urllib.error.URLError as exc: raise RuntimeError(f"Failed to upload to ElegantOTA endpoint: {exc}") from exc From 4a799271ee4ee942f81b0a38c196e9d036911e7e Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 18:45:55 +0200 Subject: [PATCH 022/125] Fix S3 build break and update ArduinoJson v7 usage --- src/CommandLoop.cpp | 7 +++---- src/api.cpp | 4 ++-- src/security.cpp | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 69b4aa8..c9363e5 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -78,8 +78,7 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, logf("msg: %s\n", msg.c_str()); #endif - const size_t capacity = JSON_OBJECT_SIZE(4) + 160; - DynamicJsonDocument doc(capacity); + JsonDocument doc; DeserializationError err = deserializeJson(doc, msg); if (err) { @@ -149,7 +148,7 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, (strncmp(command, "setPreferences", 14) == 0 || strncmp(command, "getPreferences", 14) == 0)) { JsonObject root = doc.to(); - JsonObject dataObj = root.createNestedObject("data"); + JsonObject dataObj = root["data"].to(); root["id"] = ln_id; dataObj["type"] = "preferences"; dataObj["pidKp"] = preferences.getDouble("pidKp", 1.0); @@ -160,7 +159,7 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, if (command != NULL && strncmp(command, "getData", 7) == 0) { JsonObject root = doc.to(); - JsonObject dataObj = root.createNestedObject("data"); + JsonObject dataObj = root["data"].to(); root["id"] = ln_id; float etbt[3]; getETBTReadings(etbt); diff --git a/src/api.cpp b/src/api.cpp index 0ae1bdf..907c2c4 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -47,7 +47,7 @@ void setupApi(AsyncWebServer *server) { return; } - DynamicJsonDocument doc(256); + JsonDocument doc; DeserializationError err = deserializeJson(doc, data, len); if (err) { request->send(400, "application/json", @@ -77,7 +77,7 @@ void setupApi(AsyncWebServer *server) { }); server->on("/api/info", HTTP_GET, [](AsyncWebServerRequest *request) { - StaticJsonDocument<320> doc; + JsonDocument doc; doc["firmwareVersion"] = YAEGER_FW_VERSION; doc["networkMode"] = getWifiModeString(); doc["ssid"] = getActiveSSID(); diff --git a/src/security.cpp b/src/security.cpp index 5663cd7..f745219 100644 --- a/src/security.cpp +++ b/src/security.cpp @@ -83,7 +83,7 @@ bool hasValidCsrfHeader(AsyncWebServerRequest *request) { return false; } - AsyncWebHeader *csrfHeader = request->getHeader("X-Yaeger-CSRF"); + const AsyncWebHeader *csrfHeader = request->getHeader("X-Yaeger-CSRF"); return csrfHeader->value() == csrfToken; } From f69fa0bfe0d6751ca12020da6462657df73c6de8 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 18:54:48 +0200 Subject: [PATCH 023/125] Add OTA exponential backoff retries and close point 1 TODO --- docs/code_analysis_2026-04-06.md | 8 +++---- scripts/elegantota_upload.py | 37 ++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md index 8586fd7..60f4adf 100644 --- a/docs/code_analysis_2026-04-06.md +++ b/docs/code_analysis_2026-04-06.md @@ -27,7 +27,7 @@ This is a high-level technical review of the firmware + web clients, with priori - ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ 🔄 Partially implemented. - ~~per-device admin password or token stored in NVS,~~ ✅ Implemented for `/api/wifi` and OTA Basic Auth. - ~~CSRF-resistant flow for browser UI.~~ ✅ Implemented (`X-Yaeger-CSRF` header validated for mutable REST writes). -- ~~Protect OTA route with credentials~~ and rate-limiting/backoff. 🔄 Credentials done; OTA backoff/rate-limiting still pending. +- ~~Protect OTA route with credentials and rate-limiting/backoff.~~ ✅ Credentials and exponential backoff implemented (OTA upload tooling retries transient failures). - ~~Add secure defaults in AP mode:~~ 🔄 Partially implemented. - ~~WPA2/WPA3 AP passphrase (not open AP),~~ ✅ Implemented (password-protected AP). - ~~setup-mode timeout window.~~ ✅ Implemented (AP setup timeout + restart). @@ -39,13 +39,13 @@ This is a high-level technical review of the firmware + web clients, with priori - [x] Enable AP passphrase and setup timeout window. - [x] Add auth gate for WebSocket mutable control commands. - [x] Add CSRF-resistant browser flow for authenticated actions. -- [ ] Add rate limiting / exponential backoff for OTA endpoint (REST/WS mutable endpoint throttling implemented). +- [x] Add rate limiting / exponential backoff for OTA endpoint. ### Findings - ~~Wi‑Fi credentials are accepted over **HTTP GET query params** at `/api/wifi` (`ssid` / `pass`).~~ - ~~API endpoints and OTA endpoint appear unauthenticated by default.~~ -- Device exposes AP fallback mode and local admin surface; risk is now reduced with auth + CSRF controls, with OTA-specific backoff/rate limiting still pending. +- Device exposes AP fallback mode and local admin surface; risk is reduced with auth, CSRF controls, and OTA retry/backoff protections. ### Recommendations (2026 best-practice) @@ -53,7 +53,7 @@ This is a high-level technical review of the firmware + web clients, with priori 2. ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ - ~~per-device admin password or token stored in NVS,~~ - ~~CSRF-resistant flow for browser UI.~~ -3. ~~Protect OTA route with credentials~~ and add rate-limiting/backoff. +3. ~~Protect OTA route with credentials and add rate-limiting/backoff.~~ 4. Add secure defaults in AP mode: - ~~WPA2/WPA3 AP passphrase (not open AP),~~ - ~~setup-mode timeout window.~~ diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py index ba8bd48..4924fa9 100644 --- a/scripts/elegantota_upload.py +++ b/scripts/elegantota_upload.py @@ -17,6 +17,7 @@ import hashlib import mimetypes import os +import time import urllib.error import urllib.parse import urllib.request @@ -66,6 +67,35 @@ def _build_auth_header(username: str | None, password: str | None) -> str | None return f"Basic {token}" + + +def _should_retry_http_error(code: int) -> bool: + return code in (408, 425, 429, 500, 502, 503, 504) + + +def _with_backoff(request_fn, description: str, max_attempts: int = 5): + delay_seconds = 0.5 + + for attempt in range(1, max_attempts + 1): + try: + return request_fn() + except urllib.error.HTTPError as exc: + if exc.code == 401: + raise + if attempt == max_attempts or not _should_retry_http_error(exc.code): + raise + print(f"{description} HTTP {exc.code}; retrying in {delay_seconds:.1f}s (attempt {attempt}/{max_attempts})") + time.sleep(delay_seconds) + delay_seconds = min(delay_seconds * 2, 8.0) + except urllib.error.URLError: + if attempt == max_attempts: + raise + print(f"{description} network error; retrying in {delay_seconds:.1f}s (attempt {attempt}/{max_attempts})") + time.sleep(delay_seconds) + delay_seconds = min(delay_seconds * 2, 8.0) + + raise RuntimeError(f"{description} failed after retry limit") + def _http_get(url: str, auth_header: str | None) -> tuple[int, bytes]: req = urllib.request.Request(url=url, method="GET") if auth_header: @@ -131,7 +161,7 @@ def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signatu print(f"ElegantOTA start: {start_url}") try: - status, _ = _http_get(start_url, auth_header) + status, _ = _with_backoff(lambda: _http_get(start_url, auth_header), "ElegantOTA start") except urllib.error.HTTPError as exc: if exc.code == 401: raise RuntimeError( @@ -153,7 +183,10 @@ def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signatu print(f"ElegantOTA upload: {upload_url}") try: - status, response = _http_post(upload_url, body, f"multipart/form-data; boundary={boundary}", auth_header) + status, response = _with_backoff( + lambda: _http_post(upload_url, body, f"multipart/form-data; boundary={boundary}", auth_header), + "ElegantOTA upload", + ) except urllib.error.HTTPError as exc: if exc.code == 401: raise RuntimeError( From 89d245630c49e85c8600c97c11823b187822a489 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Mon, 6 Apr 2026 19:01:14 +0200 Subject: [PATCH 024/125] Harden websocket command handling and mark analysis item 2 complete --- docs/code_analysis_2026-04-06.md | 23 +++-- src/CommandLoop.cpp | 144 +++++++++++++++++++++++++------ 2 files changed, 137 insertions(+), 30 deletions(-) diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md index 60f4adf..71e026e 100644 --- a/docs/code_analysis_2026-04-06.md +++ b/docs/code_analysis_2026-04-06.md @@ -60,6 +60,20 @@ This is a high-level technical review of the firmware + web clients, with priori ## 2) **WebSocket robustness and heap stability (high priority)** +### Implementation status (April 6, 2026 update) + +- ~~Validate parse result (`DeserializationError`) and reject malformed frames.~~ ✅ Implemented (malformed JSON and unsupported fragmented/non-text frames are rejected). +- ~~Enforce command schema validation (required fields, ranges).~~ ✅ Implemented (numeric schema checks for mutating commands and preference payloads). +- ~~Replace fixed-size `char[200]` with `measureJson` + dynamic/streamed response.~~ ✅ Implemented (`measureJson` + dynamically-sized `String` response buffer). +- ~~Add clamp logic for actuator values (e.g., fan/heater range validation) server-side regardless of client behavior.~~ ✅ Implemented (server-side clamping + logging for burner/fan/cooldown values). + +### Updated TODO list + +- [x] Reject malformed JSON payloads and unsupported WebSocket frame shapes. +- [x] Validate command schema for mutating and preference commands. +- [x] Remove fixed-size WebSocket response buffer usage. +- [x] Clamp actuator and cooldown values server-side to safe ranges. + ### Findings - `deserializeJson` return value is not checked before consuming fields. @@ -68,10 +82,10 @@ This is a high-level technical review of the firmware + web clients, with priori ### Recommendations -1. Validate parse result (`DeserializationError`) and reject malformed frames. -2. Enforce command schema validation (required fields, ranges). -3. Replace fixed-size `char[200]` with `measureJson` + dynamic/streamed response. -4. Add clamp logic for actuator values (e.g., fan/heater range validation) server-side regardless of client behavior. +1. ~~Validate parse result (`DeserializationError`) and reject malformed frames.~~ +2. ~~Enforce command schema validation (required fields, ranges).~~ +3. ~~Replace fixed-size `char[200]` with `measureJson` + dynamic/streamed response.~~ +4. ~~Add clamp logic for actuator values (e.g., fan/heater range validation) server-side regardless of client behavior.~~ ## 3) **Network resiliency and boot behavior (high priority)** @@ -172,4 +186,3 @@ This is a high-level technical review of the firmware + web clients, with priori - `npm run build` (`miniweb`, `webserver`) - `npm audit --omit=dev --json` (`webserver`) - `pio --version` (tool unavailable in environment) - diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index c9363e5..73727ca 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -14,6 +14,8 @@ Preferences preferences; namespace { constexpr unsigned long WEB_CLIENT_GRACE_PERIOD_MS = 10000; constexpr unsigned long MUTATING_CMD_MIN_INTERVAL_MS = 100; +constexpr long ACTUATOR_MIN_VALUE = 0; +constexpr long ACTUATOR_MAX_VALUE = 100; unsigned long lastWebClientDisconnectMs = 0; unsigned long lastMutatingCommandMs = 0; bool webClientGraceActive = false; @@ -45,6 +47,68 @@ bool enforceMutatingCommandAuth(AsyncWebSocketClient *client, lastMutatingCommandMs = now; return true; } + +long clampActuatorValue(long value) { + if (value < ACTUATOR_MIN_VALUE) { + return ACTUATOR_MIN_VALUE; + } + if (value > ACTUATOR_MAX_VALUE) { + return ACTUATOR_MAX_VALUE; + } + return value; +} + +bool isActuatorValueInRange(long value) { + return value >= ACTUATOR_MIN_VALUE && value <= ACTUATOR_MAX_VALUE; +} + +bool parseActuatorValue(JsonDocument &doc, const char *fieldName, long &valueOut) { + JsonVariant field = doc[fieldName]; + if (field.isNull()) { + return false; + } + if (!field.is()) { + return false; + } + valueOut = field.as(); + return true; +} + +bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, + const char *command) { + if (command == NULL) { + return true; + } + + if (strncmp(command, "setBurner", 9) == 0 || strncmp(command, "setFan", 6) == 0) { + JsonVariant valueField = doc["value"]; + if (valueField.isNull() || !valueField.is()) { + client->text("{\"error\":\"invalid schema: numeric value required\"}"); + return false; + } + } + + if (strncmp(command, "setPreferences", 14) == 0) { + if (!doc["pidKp"].isNull() && !doc["pidKp"].is()) { + client->text("{\"error\":\"invalid schema: pidKp must be numeric\"}"); + return false; + } + if (!doc["pidKi"].isNull() && !doc["pidKi"].is()) { + client->text("{\"error\":\"invalid schema: pidKi must be numeric\"}"); + return false; + } + if (!doc["pidKd"].isNull() && !doc["pidKd"].is()) { + client->text("{\"error\":\"invalid schema: pidKd must be numeric\"}"); + return false; + } + if (!doc["cooldownFanSpeed"].isNull() && !doc["cooldownFanSpeed"].is()) { + client->text("{\"error\":\"invalid schema: cooldownFanSpeed must be numeric\"}"); + return false; + } + } + + return true; +} } // namespace void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, @@ -69,18 +133,16 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, (info->opcode == WS_TEXT) ? "text" : "binary", info->len); logf("final: %d\n", info->final); #endif - String msg = ""; - - for (size_t i = 0; i < info->len; i++) { - msg += (char)data[i]; + if (info->opcode != WS_TEXT || !info->final || info->index != 0 || info->len != len) { + client->text("{\"error\":\"unsupported websocket frame\"}"); + return; } #ifdef DEBUG - logf("msg: %s\n", msg.c_str()); + logf("msg bytes: %d\n", len); #endif JsonDocument doc; - - DeserializationError err = deserializeJson(doc, msg); + DeserializationError err = deserializeJson(doc, data, len); if (err) { client->text("{\"error\":\"invalid json\"}"); return; @@ -90,6 +152,10 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, const char *command = doc["command"].as(); bool hasDirectMutatingFields = !doc["BurnerVal"].isNull() || !doc["FanVal"].isNull(); + if (!validateCommandSchema(client, doc, command)) { + return; + } + if (hasDirectMutatingFields || isMutatingCommand(command)) { if (!enforceMutatingCommandAuth(client, doc)) { return; @@ -97,26 +163,49 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } // Get BurnerVal from Artisan over Websocket - if (!doc["BurnerVal"].isNull()) { - long val = doc["BurnerVal"].as(); - logf("BurnerVal: %d\n", val); - setHeaterPower(val); + long burnerVal = 0; + if (parseActuatorValue(doc, "BurnerVal", burnerVal)) { + if (!isActuatorValueInRange(burnerVal)) { + logf("BurnerVal out of range, clamped from %d\n", burnerVal); + } + long clampedBurnerVal = clampActuatorValue(burnerVal); + logf("BurnerVal: %d\n", clampedBurnerVal); + setHeaterPower(clampedBurnerVal); + } else if (!doc["BurnerVal"].isNull()) { + client->text("{\"error\":\"invalid BurnerVal\"}"); + return; } - if (!doc["FanVal"].isNull()) { - long val = doc["FanVal"].as(); - logf("FanVal: %d\n", val); - setFanSpeed(val); + + long fanVal = 0; + if (parseActuatorValue(doc, "FanVal", fanVal)) { + if (!isActuatorValueInRange(fanVal)) { + logf("FanVal out of range, clamped from %d\n", fanVal); + } + long clampedFanVal = clampActuatorValue(fanVal); + logf("FanVal: %d\n", clampedFanVal); + setFanSpeed(clampedFanVal); + } else if (!doc["FanVal"].isNull()) { + client->text("{\"error\":\"invalid FanVal\"}"); + return; } if (command != NULL && strncmp(command, "setBurner", 9) == 0) { long val = doc["value"].as(); - logf("BurnerVal: %d\n", val); - setHeaterPower(val); + if (!isActuatorValueInRange(val)) { + logf("setBurner value out of range, clamped from %d\n", val); + } + long clampedVal = clampActuatorValue(val); + logf("BurnerVal: %d\n", clampedVal); + setHeaterPower(clampedVal); } if (command != NULL && strncmp(command, "setFan", 6) == 0) { long val = doc["value"].as(); - logf("FanVal: %d\n", val); - setFanSpeed(val); + if (!isActuatorValueInRange(val)) { + logf("setFan value out of range, clamped from %d\n", val); + } + long clampedVal = clampActuatorValue(val); + logf("FanVal: %d\n", clampedVal); + setFanSpeed(clampedVal); } // Safeguard to prevent heater fuse blowout @@ -139,8 +228,12 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } if (!doc["cooldownFanSpeed"].isNull()) { long cooldownFanSpeed = doc["cooldownFanSpeed"].as(); - logf("cooldownFanSpeed: %d\n", cooldownFanSpeed); - preferences.putLong("coolFanSpeed", cooldownFanSpeed); + if (!isActuatorValueInRange(cooldownFanSpeed)) { + logf("cooldownFanSpeed out of range, clamped from %d\n", cooldownFanSpeed); + } + long clampedCooldownFanSpeed = clampActuatorValue(cooldownFanSpeed); + logf("cooldownFanSpeed: %d\n", clampedCooldownFanSpeed); + preferences.putLong("coolFanSpeed", clampedCooldownFanSpeed); } } @@ -171,10 +264,11 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["FanVal"] = getFanSpeed(); } - char buffer[240]; - serializeJson(doc, buffer); - log(buffer); - client->text(buffer); + String response; + response.reserve(measureJson(doc) + 1); + serializeJson(doc, response); + log(response.c_str()); + client->text(response); } break; default: logf("unhandled message type: %d\n", type); From 2d4b778b1cd209eba6099435b7bf298d8e2fc75f Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 10:50:22 +0200 Subject: [PATCH 025/125] Implement network resiliency improvements from analysis item 3 --- docs/code_analysis_2026-04-06.md | 18 +++++-- src/main.cpp | 14 ++++-- src/wifi_setup.cpp | 83 +++++++++++++++++++++----------- 3 files changed, 81 insertions(+), 34 deletions(-) diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md index 71e026e..deeebd1 100644 --- a/docs/code_analysis_2026-04-06.md +++ b/docs/code_analysis_2026-04-06.md @@ -89,6 +89,18 @@ This is a high-level technical review of the firmware + web clients, with priori ## 3) **Network resiliency and boot behavior (high priority)** +### Implementation status (April 11, 2026 update) + +- ~~Convert Wi‑Fi connect to non-blocking state machine (or bounded async retry steps).~~ ✅ Implemented (connect attempts now run without blocking startup loop and time out to AP fallback). +- ~~Keep loop tick deterministic by moving periodic tasks to elapsed-time scheduling.~~ ✅ Implemented (`millis()`-driven 10ms fast tick for cleanup/safety/sensor polling). +- ~~Add watchdog-friendly design: avoid long blocking sections in startup/connect paths.~~ ✅ Implemented (removed blocking connect loop and replaced fixed loop delay with cooperative `yield()`). + +### Updated TODO list + +- [x] Replace blocking Wi‑Fi connect loop with non-blocking attempt tracking + timeout. +- [x] Move periodic runtime tasks to elapsed-time scheduling for deterministic ticks. +- [x] Remove fixed loop sleep and use cooperative yielding for watchdog friendliness. + ### Findings - Wi‑Fi connect routine blocks in a loop up to ~10s with `delay(1000)` retries. @@ -96,9 +108,9 @@ This is a high-level technical review of the firmware + web clients, with priori ### Recommendations -1. Convert Wi‑Fi connect to non-blocking state machine (or bounded async retry steps). -2. Keep loop tick deterministic by moving periodic tasks to elapsed-time scheduling. -3. Add watchdog-friendly design: avoid long blocking sections in startup/connect paths. +1. ~~Convert Wi‑Fi connect to non-blocking state machine (or bounded async retry steps).~~ +2. ~~Keep loop tick deterministic by moving periodic tasks to elapsed-time scheduling.~~ +3. ~~Add watchdog-friendly design: avoid long blocking sections in startup/connect paths.~~ ## 4) **Frontend modernization path (high priority, medium effort)** diff --git a/src/main.cpp b/src/main.cpp index 9ba9004..8a44557 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,6 +29,8 @@ const char *host = "esp32 Roaster"; // Create a WebSocket object AsyncWebSocket ws("/ws"); AsyncWebServer server(80); +constexpr unsigned long FAST_TICK_INTERVAL_MS = 10; +unsigned long lastFastTickMs = 0; void setupSimulation(AsyncWebSocket *ws); void updateSimulation(); @@ -110,11 +112,15 @@ void setup(void) { } void loop(void) { + unsigned long now = millis(); ElegantOTA.loop(); maintainWifiConnection(); - ws.cleanupClients(); - updateConnectionSafety(&ws); - delay(10); - takeReadings(); + if (now - lastFastTickMs >= FAST_TICK_INTERVAL_MS) { + lastFastTickMs = now; + ws.cleanupClients(); + updateConnectionSafety(&ws); + takeReadings(); + } updateHeater(); + yield(); } diff --git a/src/wifi_setup.cpp b/src/wifi_setup.cpp index 84d0700..39b8a5d 100644 --- a/src/wifi_setup.cpp +++ b/src/wifi_setup.cpp @@ -30,8 +30,14 @@ WiFiParams params; const char *yaegerHostname = "yaeger.local"; unsigned long lastReconnectAttemptMs = 0; constexpr unsigned long WIFI_RECONNECT_INTERVAL_MS = 5000; +constexpr unsigned long WIFI_CONNECT_TIMEOUT_MS = 10000; +constexpr unsigned long WIFI_CONNECT_STATUS_LOG_INTERVAL_MS = 1000; constexpr unsigned long AP_SETUP_TIMEOUT_MS = 15UL * 60UL * 1000UL; unsigned long apModeStartMs = 0; +unsigned long wifiConnectAttemptStartMs = 0; +unsigned long lastWifiConnectLogMs = 0; +bool wifiConnectInProgress = false; +bool wifiConnectionAnnounced = false; void setupAP() { WiFi.mode(WIFI_AP); @@ -45,31 +51,13 @@ void setupAP() { void connectToWifi() { WiFi.mode(WIFI_STA); - WiFi.begin(params.getSSID(), params.getPass()); WiFi.setTxPower(WIFI_POWER_8_5dBm); - int wifiCounter = 0; - while (WiFi.status() != WL_CONNECTED) { - if (WiFi.status() == WL_CONNECT_FAILED) { - log("Connect failed, restoring AP"); - setupAP(); - break; - } - wifiCounter++; - delay(1000); - log("."); - if (wifiCounter > 10) { - WiFi.disconnect(true); - delay(100); - setupAP(); - break; - } - } - log(""); - log("Connected to "); - log(WiFi.SSID().c_str()); - log("IP address: "); - log(WiFi.localIP().toString().c_str()); + wifiConnectAttemptStartMs = millis(); + lastWifiConnectLogMs = 0; + wifiConnectInProgress = true; + wifiConnectionAnnounced = false; + log("Starting non-blocking WiFi connect attempt"); } void setupWifi() { @@ -166,16 +154,57 @@ void maintainWifiConnection() { } wl_status_t status = WiFi.status(); - if (status == WL_CONNECTED || status == WL_IDLE_STATUS) { + unsigned long now = millis(); + + if (status == WL_CONNECTED) { + if (!wifiConnectionAnnounced) { + log(""); + log("Connected to "); + log(WiFi.SSID().c_str()); + log("IP address: "); + log(WiFi.localIP().toString().c_str()); + wifiConnectionAnnounced = true; + } + wifiConnectInProgress = false; + return; + } + + if (wifiConnectInProgress) { + if (status == WL_CONNECT_FAILED) { + log("Connect failed, restoring AP"); + WiFi.disconnect(true); + setupAP(); + wifiConnectInProgress = false; + wifiConnectionAnnounced = false; + return; + } + + if (now - wifiConnectAttemptStartMs >= WIFI_CONNECT_TIMEOUT_MS) { + log("WiFi connect timed out, restoring AP"); + WiFi.disconnect(true); + setupAP(); + wifiConnectInProgress = false; + wifiConnectionAnnounced = false; + return; + } + + if (lastWifiConnectLogMs == 0 || + now - lastWifiConnectLogMs >= WIFI_CONNECT_STATUS_LOG_INTERVAL_MS) { + log("."); + lastWifiConnectLogMs = now; + } + return; + } + + if (status == WL_IDLE_STATUS) { return; } - unsigned long now = millis(); if (now - lastReconnectAttemptMs < WIFI_RECONNECT_INTERVAL_MS) { return; } lastReconnectAttemptMs = now; - logf("WiFi not connected (status=%d), attempting reconnect", status); - WiFi.reconnect(); + logf("WiFi not connected (status=%d), starting reconnect", status); + connectToWifi(); } From 76673f71e8cc1e81055d4c2de45c5f838d8d27ae Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 10:57:01 +0200 Subject: [PATCH 026/125] Finalize frontend modernization path with miniweb-first workflow --- README.md | 14 ++++++++++++++ build_and_flash.sh | 23 ++++------------------- docs/code_analysis_2026-04-06.md | 18 +++++++++++++++--- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index d9e308e..ca19f5b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ your home wifi, or `192.168.4.1` if Yaeger creates its own access point. The web UI now includes a **Version & Network Info** section that shows the Web UI version/build timestamp and device firmware/network details (mode, SSID, IP, hostname) so you can quickly check when the currently loaded build was last updated. +### Frontend status + +- `miniweb` (TypeScript + Vite) is the **primary and supported** web UI. +- `webserver` (legacy Svelte/Rollup) is now considered **deprecated/frozen** and should not receive new feature work. +- Project scripts and firmware asset packaging target `miniweb` by default. + #### Using Yaeger on the go If Yaeger can't connect to your preferred Wifi, it will create its own access point. Perfect for when out and about :grin: @@ -99,6 +105,14 @@ For a **single-command OTA update of the whole project** (frontend files + firmw This builds `miniweb`, then runs OTA in two explicit steps: (1) upload LittleFS (`buildfs` + `uploadfs`) and (2) upload firmware (`upload`). The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfs-ng`, `pyyaml`), and auto-retries if PlatformIO reports missing Python modules. +For local frontend builds, use npm from `miniweb`: + +```bash +cd miniweb +npm ci +npm run build +``` + ## Latest features ### PID diff --git a/build_and_flash.sh b/build_and_flash.sh index 4082742..6ee97ee 100755 --- a/build_and_flash.sh +++ b/build_and_flash.sh @@ -31,26 +31,13 @@ fi echo "Using PlatformIO environment: $PIO_ENV" - -read -p "Choose frontend (r for reyaeger, empty for classic): " frontend - -if [ $frontend = 'r' ]; then - -echo "reyaeger download"; -curl -L https://github.com/RobTS/reyaeger/releases/latest/download/reyaeger.zip > reyaeger.zip -rm -rf data -mkdir data -unzip -d ./data ./reyaeger.zip - -else - -# Step 1: Navigate to the miniweb directory +# Step 1: Navigate to the primary frontend (miniweb) echo "Navigating to miniweb..." cd miniweb || { echo "miniweb folder not found!"; exit 1; } -# Step 2: Install dependencies -echo "Installing dependencies with npm..." -npm install || { echo "npm install failed!"; exit 1; } +# Step 2: Install dependencies with npm (standardized package manager) +echo "Installing dependencies with npm ci..." +npm ci || { echo "npm ci failed!"; exit 1; } # Step 3: Build the web assets echo "Building the web project..." @@ -58,9 +45,7 @@ npm run build || { echo "npm build failed!"; exit 1; } # Step 4: Return to the project root echo "Returning to the project root..." - cd .. || exit 1 -fi # Step 5: Erase the device memory (optional but recommended) echo "Erasing the device memory..." diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md index deeebd1..503516a 100644 --- a/docs/code_analysis_2026-04-06.md +++ b/docs/code_analysis_2026-04-06.md @@ -114,6 +114,18 @@ This is a high-level technical review of the firmware + web clients, with priori ## 4) **Frontend modernization path (high priority, medium effort)** +### Implementation status (April 11, 2026 update) + +- ~~Make `miniweb` the single primary frontend and define deprecation timeline for `webserver`.~~ ✅ Implemented (project docs + build path now explicitly designate `miniweb` as primary and `webserver` as deprecated/frozen). +- ~~If legacy UI must remain, plan migration to modern Svelte/Vite stack.~~ ✅ Implemented as policy decision (legacy kept as frozen fallback with no new feature investment). +- ~~Standardize package manager/lockfile strategy (npm vs yarn) to reduce CI drift.~~ ✅ Implemented for active frontend path (`build_and_flash.sh` now uses `npm ci` for deterministic `miniweb` installs). + +### Updated TODO list + +- [x] Declare `miniweb` as canonical UI and deprecate legacy `webserver`. +- [x] Freeze legacy UI scope to maintenance-only fallback. +- [x] Standardize active frontend build path on deterministic npm installs. + ### Findings - Repository contains **two web UIs** (`webserver` legacy Svelte 3 + Rollup, and `miniweb` TypeScript + Vite). @@ -121,9 +133,9 @@ This is a high-level technical review of the firmware + web clients, with priori ### Recommendations -1. Make `miniweb` the single primary frontend and define deprecation timeline for `webserver`. -2. If legacy UI must remain, plan migration to modern Svelte/Vite stack. -3. Standardize package manager/lockfile strategy (npm vs yarn) to reduce CI drift. +1. ~~Make `miniweb` the single primary frontend and define deprecation timeline for `webserver`.~~ +2. ~~If legacy UI must remain, plan migration to modern Svelte/Vite stack.~~ +3. ~~Standardize package manager/lockfile strategy (npm vs yarn) to reduce CI drift.~~ ## 5) **Dependency and supply-chain updates (high priority)** From 29c86a5f9c730820cac24f86433d2813679e721b Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:23:08 +0200 Subject: [PATCH 027/125] Remove legacy webserver frontend and svelte leftovers --- README.md | 6 +- package.json | 3 +- webserver/.gitignore | 4 - webserver/README.md | 107 - webserver/package-lock.json | 2548 ----------------- webserver/package.json | 31 - webserver/public/favicon.png | Bin 3127 -> 0 bytes webserver/public/global.css | 63 - webserver/public/index.html | 48 - webserver/rollup.config.js | 78 - webserver/scripts/setupTypeScript.js | 134 - webserver/src/App.svelte | 51 - webserver/src/components/EventButtons.svelte | 13 - webserver/src/components/FanSlider.svelte | 16 - webserver/src/components/HeaterSlider.svelte | 16 - webserver/src/components/RoastGraph.svelte | 35 - .../src/components/RoastSettingsDialog.svelte | 13 - .../src/components/SettingsDialog.svelte | 13 - .../src/components/TemperatureReadout.svelte | 10 - webserver/src/main.js | 10 - webserver/src/store.js | 47 - 21 files changed, 4 insertions(+), 3242 deletions(-) delete mode 100644 webserver/.gitignore delete mode 100644 webserver/README.md delete mode 100644 webserver/package-lock.json delete mode 100644 webserver/package.json delete mode 100644 webserver/public/favicon.png delete mode 100644 webserver/public/global.css delete mode 100644 webserver/public/index.html delete mode 100644 webserver/rollup.config.js delete mode 100644 webserver/scripts/setupTypeScript.js delete mode 100644 webserver/src/App.svelte delete mode 100644 webserver/src/components/EventButtons.svelte delete mode 100644 webserver/src/components/FanSlider.svelte delete mode 100644 webserver/src/components/HeaterSlider.svelte delete mode 100644 webserver/src/components/RoastGraph.svelte delete mode 100644 webserver/src/components/RoastSettingsDialog.svelte delete mode 100644 webserver/src/components/SettingsDialog.svelte delete mode 100644 webserver/src/components/TemperatureReadout.svelte delete mode 100644 webserver/src/main.js delete mode 100644 webserver/src/store.js diff --git a/README.md b/README.md index ca19f5b..1c31507 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,9 @@ The web UI now includes a **Version & Network Info** section that shows the Web ### Frontend status -- `miniweb` (TypeScript + Vite) is the **primary and supported** web UI. -- `webserver` (legacy Svelte/Rollup) is now considered **deprecated/frozen** and should not receive new feature work. -- Project scripts and firmware asset packaging target `miniweb` by default. +- `miniweb` (TypeScript + Vite) is the **only supported** web UI in this repository. +- The old `webserver` Svelte/Rollup frontend and related legacy files have been removed. +- Project scripts and firmware asset packaging target `miniweb`. #### Using Yaeger on the go diff --git a/package.json b/package.json index ae1ca57..1c88ecc 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "chartjs-plugin-trendline": "^2.1.6" }, "devDependencies": { - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.2" + "prettier": "^3.4.2" } } diff --git a/webserver/.gitignore b/webserver/.gitignore deleted file mode 100644 index da93220..0000000 --- a/webserver/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/node_modules/ -/public/build/ - -.DS_Store diff --git a/webserver/README.md b/webserver/README.md deleted file mode 100644 index d488b3c..0000000 --- a/webserver/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# This repo is no longer maintained. Consider using `npm init vite` and selecting the `svelte` option or — if you want a full-fledged app framework — use [SvelteKit](https://kit.svelte.dev), the official application framework for Svelte. - ---- - -# svelte app - -This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template. - -To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit): - -```bash -npx degit sveltejs/template svelte-app -cd svelte-app -``` - -*Note that you will need to have [Node.js](https://nodejs.org) installed.* - - -## Get started - -Install the dependencies... - -```bash -cd svelte-app -npm install -``` - -...then start [Rollup](https://rollupjs.org): - -```bash -npm run dev -``` - -Navigate to [localhost:8080](http://localhost:8080). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes. - -By default, the server will only respond to requests from localhost. To allow connections from other computers, edit the `sirv` commands in package.json to include the option `--host 0.0.0.0`. - -If you're using [Visual Studio Code](https://code.visualstudio.com/) we recommend installing the official extension [Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). If you are using other editors you may need to install a plugin in order to get syntax highlighting and intellisense. - -## Building and running in production mode - -To create an optimised version of the app: - -```bash -npm run build -``` - -You can run the newly built app with `npm run start`. This uses [sirv](https://github.com/lukeed/sirv), which is included in your package.json's `dependencies` so that the app will work when you deploy to platforms like [Heroku](https://heroku.com). - - -## Single-page app mode - -By default, sirv will only respond to requests that match files in `public`. This is to maximise compatibility with static fileservers, allowing you to deploy your app anywhere. - -If you're building a single-page app (SPA) with multiple routes, sirv needs to be able to respond to requests for *any* path. You can make it so by editing the `"start"` command in package.json: - -```js -"start": "sirv public --single" -``` - -## Using TypeScript - -This template comes with a script to set up a TypeScript development environment, you can run it immediately after cloning the template with: - -```bash -node scripts/setupTypeScript.js -``` - -Or remove the script via: - -```bash -rm scripts/setupTypeScript.js -``` - -If you want to use `baseUrl` or `path` aliases within your `tsconfig`, you need to set up `@rollup/plugin-alias` to tell Rollup to resolve the aliases. For more info, see [this StackOverflow question](https://stackoverflow.com/questions/63427935/setup-tsconfig-path-in-svelte). - -## Deploying to the web - -### With [Vercel](https://vercel.com) - -Install `vercel` if you haven't already: - -```bash -npm install -g vercel -``` - -Then, from within your project folder: - -```bash -cd public -vercel deploy --name my-project -``` - -### With [surge](https://surge.sh/) - -Install `surge` if you haven't already: - -```bash -npm install -g surge -``` - -Then, from within your project folder: - -```bash -npm run build -surge public my-project.surge.sh -``` diff --git a/webserver/package-lock.json b/webserver/package-lock.json deleted file mode 100644 index 78cb0e0..0000000 --- a/webserver/package-lock.json +++ /dev/null @@ -1,2548 +0,0 @@ -{ - "name": "svelte-app", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "svelte-app", - "version": "1.0.0", - "dependencies": { - "@smui/button": "^7.0.0", - "@smui/dialog": "^7.0.0", - "@smui/slider": "^7.0.0", - "chart.js": "^4.4.5", - "sirv-cli": "^2.0.0", - "svelte-material-ui": "^7.0.0" - }, - "devDependencies": { - "@mdi/js": "^7.4.47", - "@rollup/plugin-commonjs": "^24.0.0", - "@rollup/plugin-node-resolve": "^15.0.0", - "@rollup/plugin-terser": "^0.4.0", - "@smui/textfield": "^7.0.0", - "rollup": "^3.15.0", - "rollup-plugin-css-only": "^4.3.0", - "rollup-plugin-livereload": "^2.0.0", - "rollup-plugin-svelte": "^7.1.2", - "svelte": "^3.55.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@kurkle/color": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", - "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", - "license": "MIT" - }, - "node_modules/@material/animation": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/animation/-/animation-14.0.0.tgz", - "integrity": "sha512-VlYSfUaIj/BBVtRZI8Gv0VvzikFf+XgK0Zdgsok5c1v5DDnNz5tpB8mnGrveWz0rHbp1X4+CWLKrTwNmjrw3Xw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@material/banner": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/banner/-/banner-14.0.0.tgz", - "integrity": "sha512-z0WPBVQxbQVcV1km4hFD40xBEeVWYtCzl2jrkHd8xXexP/fMvXkFU1UfwSWvY3jlWx//j4/Xd7VpnRdEXS4RLQ==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/button": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/base": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/base/-/base-14.0.0.tgz", - "integrity": "sha512-Ou7vS7n1H4Y10MUZyYAbt6H0t67c6urxoCgeVT7M38aQlaNUwFMODp7KT/myjYz2YULfhu3PtfSV3Sltgac9mA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@material/button": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/button/-/button-14.0.0.tgz", - "integrity": "sha512-dqqHaJq0peyXBZupFzCjmvScrfljyVU66ZCS3oldsaaj5iz8sn33I/45Z4zPzdR5F5z8ExToHkRcXhakj1UEAA==", - "license": "MIT", - "dependencies": { - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/touch-target": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/card": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/card/-/card-14.0.0.tgz", - "integrity": "sha512-SnpYWUrCb92meGYLXV7qa/k40gnHR6rPki6A1wz0OAyG2twY48f0HLscAqxBLvbbm1LuRaqjz0RLKGH3VzxZHw==", - "license": "MIT", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/checkbox": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/checkbox/-/checkbox-14.0.0.tgz", - "integrity": "sha512-OoqwysCqvj1d0cRmEwVWPvg5OqYAiCFpE6Wng6me/Cahfe4xgRxSPa37WWqsClw20W7PG/5RrYRCBtc6bUUUZA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/chips": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/chips/-/chips-14.0.0.tgz", - "integrity": "sha512-SfZX/Ovdq4NgjdtIr/N1O3fEHisZC+t8G8629OV/NrniSS6rKOa+q1mImzna8R4pfuYO+7nT5nZewQpL/JSYaQ==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/checkbox": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/touch-target": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/circular-progress": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/circular-progress/-/circular-progress-14.0.0.tgz", - "integrity": "sha512-7EdkP6ty54g6qs6zzlsw29vWlUyrcSWr9b4pGGx4D/iNJww+eyxXZ07iWoNOr4uLgguauWEft2axpQiFCwFD0g==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/progress-indicator": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/data-table": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/data-table/-/data-table-14.0.0.tgz", - "integrity": "sha512-tnmLawGaMtnp29KH8pX99bqeKmFODE+MtRUTt6TauupkEfQE/wd0Um4JQDFiI0kCch7uF3r/NmQKyKuan10hXw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/checkbox": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/icon-button": "^14.0.0", - "@material/linear-progress": "^14.0.0", - "@material/list": "^14.0.0", - "@material/menu": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/select": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/density": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/density/-/density-14.0.0.tgz", - "integrity": "sha512-NlxXBV5XjNsKd8UXF4K/+fOXLxoFNecKbsaQO6O2u+iG8QBfFreKRmkhEBb2hPPwC3w8nrODwXX0lHV+toICQw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@material/dialog": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/dialog/-/dialog-14.0.0.tgz", - "integrity": "sha512-E07NEE4jP8jHaw/y2Il2R1a3f4wDFh2sgfCBtRO/Xh0xxJUMuQ7YXo/F3SAA8jfMbbkUv/PHdJUM3I3HmI9mAA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/button": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/icon-button": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/touch-target": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/dom": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/dom/-/dom-14.0.0.tgz", - "integrity": "sha512-8t88XyacclTj8qsIw9q0vEj4PI2KVncLoIsIMzwuMx49P2FZg6TsLjor262MI3Qs00UWAifuLMrhnOnfyrbe7Q==", - "license": "MIT", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/drawer": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/drawer/-/drawer-14.0.0.tgz", - "integrity": "sha512-VPrxMIhbkXVbfH7aMFV+Um0tjOVrU/Y65X2hWsVdmjASadE8C5UYjIE3vjL1DM1M+zIa3qZZRUWqz0j1zqbr3w==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/list": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/elevation": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/elevation/-/elevation-14.0.0.tgz", - "integrity": "sha512-Di3tkxTpXwvf1GJUmaC8rd+zVh5dB2SWMBGagL4+kT8UmjSISif/OPRGuGnXs3QhF6nmEjkdC0ijdZLcYQkepw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/fab": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/fab/-/fab-14.0.0.tgz", - "integrity": "sha512-s4rrw2TLU8ITKopHSTEHuJEFsGEZsb+ijwW16pQt0h9GArxPGaALT+CCJIPjf75D3wPEEMW0vnLj7oMoII2VFg==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/touch-target": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/feature-targeting": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/feature-targeting/-/feature-targeting-14.0.0.tgz", - "integrity": "sha512-a5WGgHEq5lJeeNL5yevtgoZjBjXWy6+klfVWQEh8oyix/rMJygGgO7gEc52uv8fB8uAIoYEB3iBMOv8jRq8FeA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@material/floating-label": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/floating-label/-/floating-label-14.0.0.tgz", - "integrity": "sha512-Aq8BboP1sbNnOtsV72AfaYirHyOrQ/GKFoLrZ1Jt+ZGIAuXPETcj9z7nQDznst0ZeKcz420PxNn9tsybTbeL/Q==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/focus-ring": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/focus-ring/-/focus-ring-14.0.0.tgz", - "integrity": "sha512-fqqka6iSfQGJG3Le48RxPCtnOiaLGPDPikhktGbxlyW9srBVMgeCiONfHM7IT/1eu80O0Y67Lh/4ohu5+C+VAQ==", - "license": "MIT", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0" - } - }, - "node_modules/@material/form-field": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/form-field/-/form-field-14.0.0.tgz", - "integrity": "sha512-k1GNBj6Sp8A7Xsn5lTMp5DkUkg60HX7YkQIRyFz1qCDCKJRWh/ou7Z45GMMgKmG3aF6LfjIavc7SjyCl8e5yVg==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/icon-button": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/icon-button/-/icon-button-14.0.0.tgz", - "integrity": "sha512-wHMqzm7Q/UwbWLoWv32Li1r2iVYxadIrwTNxT0+p+7NdfI3lEwMN3NoB0CvoJnHTljjXDzce0KJ3nZloa0P0gA==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/image-list": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/image-list/-/image-list-14.0.0.tgz", - "integrity": "sha512-vx/7WCMbiZoy/R+DmO7r0N3jWzFjlvvDMeBpXt0btglWP3EYbVnDqzseW4u1TtY+IBbJldW/DsiCN1oLnlEVxw==", - "license": "MIT", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/layout-grid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/layout-grid/-/layout-grid-14.0.0.tgz", - "integrity": "sha512-tAce0PR/c85VI2gf1HUdM0Y15ZWpfZWAFIwaCRW1+jnOLWnG1/aOJYLlzqtVEv2m0TS1R1WRRGN3Or+CWvpDRA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@material/line-ripple": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-14.0.0.tgz", - "integrity": "sha512-Rx9eSnfp3FcsNz4O+fobNNq2PSm5tYHC3hRpY2ZK3ghTvgp3Y40/soaGEi/Vdg0F7jJXRaBSNOe6p5t9CVfy8Q==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/linear-progress": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/linear-progress/-/linear-progress-14.0.0.tgz", - "integrity": "sha512-MGIAWMHMW6TSV/TNWyl5N/escpDHk3Rq6hultFif+D9adqbOXrtfZZIFPLj1FpMm1Ucnj6zgOmJHgCDsxRVNIA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/progress-indicator": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/list": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/list/-/list-14.0.0.tgz", - "integrity": "sha512-AFaBGV9vQyfnG8BT2R3UGVdF5w2SigQqBH+qbOSxQhk4BgVvhDfJUIKT415poLNMdnaDtcuYz+ZWvVNoRDaL7w==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/menu": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/menu/-/menu-14.0.0.tgz", - "integrity": "sha512-oU6GjbYnkG6a5nX9HUSege5OQByf6yUteEij8fpf0ci3f5BWf/gr39dnQ+rfl+q119cW0WIEmVK2YJ/BFxMzEQ==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/list": "^14.0.0", - "@material/menu-surface": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/menu-surface": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/menu-surface/-/menu-surface-14.0.0.tgz", - "integrity": "sha512-wRz3UCrhJ4kRrijJEbvIPRa0mqA5qkQmKXjBH4Xu1ApedZruP+OM3Qb2Bj4XugCA3eCXpiohg+gdyTAX3dVQyw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/notched-outline": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/notched-outline/-/notched-outline-14.0.0.tgz", - "integrity": "sha512-6S58DlWmhCDr4RQF2RuwqANxlmLdHtWy2mF4JQLD9WOiCg4qY9eCQnMXu3Tbhr7f/nOZ0vzc7AtA3vfJoZmCSw==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/floating-label": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/progress-indicator": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/progress-indicator/-/progress-indicator-14.0.0.tgz", - "integrity": "sha512-09JRTuIySxs670Tcy4jVlqCUbyrO+Ad6z3nHnAi8pYl74duco4n/9jTROV0mlFdr9NIFifnd08lKbiFLDmfJGQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@material/radio": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/radio/-/radio-14.0.0.tgz", - "integrity": "sha512-VwPOi5fAoZXL3RhQJ6iDWTR34L6JXlwd5VXli8ZhzNHnUzcmpMODrRhGVew4Z5uuNj6/n2Jbn1zcS9XmmqjssA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/ripple": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-14.0.0.tgz", - "integrity": "sha512-9XoGBFd5JhFgELgW7pqtiLy+CnCIcV2s9cQ2BWbOQeA8faX9UZIDUx/g76nHLZ7UzKFtsULJxZTwORmsEt2zvw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/rtl": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-14.0.0.tgz", - "integrity": "sha512-xl6OZYyRjuiW2hmbjV2omMV8sQtfmKAjeWnD1RMiAPLCTyOW9Lh/PYYnXjxUrNa0cRwIIbOn5J7OYXokja8puA==", - "license": "MIT", - "dependencies": { - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/segmented-button": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/segmented-button/-/segmented-button-14.0.0.tgz", - "integrity": "sha512-6es7PPNX3T3h7bOLyb8L38hMoTXqBs5XX8XCKycKZG2Dm4stac/yYMKKO/q3MOn36t37s+JAVTjyRB8HnJu5Gg==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/select": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/select/-/select-14.0.0.tgz", - "integrity": "sha512-4aY1kUHEnbOCRG3Tkuuk8yFfyNYSvOstBbjiYE/Z1ZGF3P1z+ON35iLatP84LvNteX4F1EMO2QAta2QbLRMAkw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/floating-label": "^14.0.0", - "@material/line-ripple": "^14.0.0", - "@material/list": "^14.0.0", - "@material/menu": "^14.0.0", - "@material/menu-surface": "^14.0.0", - "@material/notched-outline": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/shape": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/shape/-/shape-14.0.0.tgz", - "integrity": "sha512-o0mJB0+feOv473KckI8gFnUo8IQAaEA6ynXzw3VIYFjPi48pJwrxa0mZcJP/OoTXrCbDzDeFJfDPXEmRioBb9A==", - "license": "MIT", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/slider": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/slider/-/slider-14.0.0.tgz", - "integrity": "sha512-m5RqySIps1vhAQnGp2eg4Sh2Ss6bzrZm10TWBw2cNFHmbiI72rK2EeFnMsBXAarplY0cot/FaMuj91VP36gKFQ==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/snackbar": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/snackbar/-/snackbar-14.0.0.tgz", - "integrity": "sha512-28uQBj9bw7BalNarK9j8/aVW4Ys5aRaGHoWH+CeYvAjqQUJkrYoqM52aiKhBwqrjBPMJHk1aXthe3YbzMBm6vA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/button": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/icon-button": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/switch": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/switch/-/switch-14.0.0.tgz", - "integrity": "sha512-vHVKzbvHVKGSrkMB1lZAl8z3eJ8sPRnSR+DWn+IhqHcTsDdDyly2NNj4i2vTSrEA39CztGqkx0OnKM4vkpiZHw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/tab": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/tab/-/tab-14.0.0.tgz", - "integrity": "sha512-jGSQdp6BvZOVnvGbv0DvNDJL2lHYVFtKGehV0gSZ7FrjHK6gZnKZjWOVwt1NPu9ig9zy85vPRFpvFTeje1KZpg==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/tab-indicator": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/tab-bar": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/tab-bar/-/tab-bar-14.0.0.tgz", - "integrity": "sha512-G/UYEOIcljCHlkj3iCRGIz4zE9RVcsdC9wuOR6LE2rla6EGyT0x2psNlL0pIMROjXoB0HGda/gB90ovzKcbURA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/tab": "^14.0.0", - "@material/tab-indicator": "^14.0.0", - "@material/tab-scroller": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/tab-indicator": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/tab-indicator/-/tab-indicator-14.0.0.tgz", - "integrity": "sha512-wfq136fsJGqtCIW8x1wFQHgRr7dIQ9SWqp6WG4FQGHpSzliNDA23/bdBUjh3lX2U+mfbdsFmZWEPy06jg2uc5g==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/tab-scroller": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/tab-scroller/-/tab-scroller-14.0.0.tgz", - "integrity": "sha512-wadETsRM7vT4mRjXedaPXxI/WFSSgqHRNI//dORJ6627hoiJfLb5ixwUKTYk9zTz6gNwAlRTrKh98Dr9T7n7Kw==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/tab": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/textfield": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/textfield/-/textfield-14.0.0.tgz", - "integrity": "sha512-HGbtAlvlIB2vWBq85yw5wQeeP3Kndl6Z0TJzQ6piVtcfdl2mPyWhuuVHQRRAOis3rCIaAAaxCQYYTJh8wIi0XQ==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/floating-label": "^14.0.0", - "@material/line-ripple": "^14.0.0", - "@material/notched-outline": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/tokens": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/theme": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/theme/-/theme-14.0.0.tgz", - "integrity": "sha512-6/SENWNIFuXzeHMPHrYwbsXKgkvCtWuzzQ3cUu4UEt3KcQ5YpViazIM6h8ByYKZP8A9d8QpkJ0WGX5btGDcVoA==", - "license": "MIT", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/tokens": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/tokens/-/tokens-14.0.0.tgz", - "integrity": "sha512-SXgB9VwsKW4DFkHmJfDIS0x0cGdMWC1D06m6z/WQQ5P5j6/m0pKrbHVlrLzXcRjau+mFhXGvj/KyPo9Pp/Rc8Q==", - "license": "MIT", - "dependencies": { - "@material/elevation": "^14.0.0" - } - }, - "node_modules/@material/tooltip": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/tooltip/-/tooltip-14.0.0.tgz", - "integrity": "sha512-rp7sOuVE1hmg4VgBJMnSvtDbSzctL42X7y1yv8ukuu40Sli+H5FT0Zbn351EfjJgQWg/AlXA6+reVXkXje8JzQ==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/top-app-bar": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/top-app-bar/-/top-app-bar-14.0.0.tgz", - "integrity": "sha512-uPej5vHgZnlSB1+koiA9FnabXrHh3O/Npl2ifpUgDVwHDSOxKvLp2LNjyCO71co1QLNnNHIU0xXv3B97Gb0rpA==", - "license": "MIT", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/base": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/touch-target": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/touch-target/-/touch-target-14.0.0.tgz", - "integrity": "sha512-o3kvxmS4HkmZoQTvtzLJrqSG+ezYXkyINm3Uiwio1PTg67pDgK5FRwInkz0VNaWPcw9+5jqjUQGjuZMtjQMq8w==", - "license": "MIT", - "dependencies": { - "@material/base": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@material/typography": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/typography/-/typography-14.0.0.tgz", - "integrity": "sha512-/QtHBYiTR+TPMryM/CT386B2WlAQf/Ae32V324Z7P40gHLKY/YBXx7FDutAWZFeOerq/two4Nd2aAHBcMM2wMw==", - "license": "MIT", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/theme": "^14.0.0", - "tslib": "^2.1.0" - } - }, - "node_modules/@mdi/js": { - "version": "7.4.47", - "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", - "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.28", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", - "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.1.0.tgz", - "integrity": "sha512-eSL45hjhCWI0jCCXcNtLVqM5N1JlBGvlFfY0m6oOYnLCJ6N0qEXoZql4sY2MOUArzhH4SA/qBpTxvvZp2Sc+DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "glob": "^8.0.3", - "is-reference": "1.2.1", - "magic-string": "^0.27.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz", - "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "serialize-javascript": "^6.0.1", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", - "integrity": "sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@smui-extra/accordion": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui-extra/accordion/-/accordion-7.0.0.tgz", - "integrity": "sha512-yi35uaZYkZR7x1WxUMpKngXwBzNhJqRQ8U4Fyyl8TPg1mxVXKHvhUajNKrbbWIdKqBSZZrSScGj6Qvl/4HniGQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/paper": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui-extra/autocomplete": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui-extra/autocomplete/-/autocomplete-7.0.0.tgz", - "integrity": "sha512-My6ruPmHk0Q0OLENEDQwXYb2WHymBdxzNAg3dmYIpzW8qlNvdeyVcIsn001+FTaZrUe9axVu47rHttm5EFzXYg==", - "license": "Apache-2.0", - "dependencies": { - "@smui/common": "^7.0.0", - "@smui/list": "^7.0.0", - "@smui/menu": "^7.0.0", - "@smui/menu-surface": "^7.0.0", - "@smui/textfield": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui-extra/badge": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui-extra/badge/-/badge-7.0.0.tgz", - "integrity": "sha512-DJ1YW/1JuId6pXCD2xFkQnPkSByQGz48MpclWme42YhZhx1fIGHnNBQ1hop2rOwCZ9SVXgPPzMxwe7X9WY4b3g==", - "license": "Apache-2.0", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui-extra/bottom-app-bar": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui-extra/bottom-app-bar/-/bottom-app-bar-7.0.0.tgz", - "integrity": "sha512-oaoeuwcibwZVxe0bRa9PaBe12FfM0TxAVoqml0OthEtqARpRsS23HcfTyPDOoZ9i6JTmjzuHEMJA1043llsq6Q==", - "license": "Apache-2.0", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/theme": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/paper": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/banner": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/banner/-/banner-7.0.0.tgz", - "integrity": "sha512-R+9RfIaN1OlMs8j0pCbINSsI5ISYZJ4BSw1QGs6gVhvwUs0I2ld43MzDrXbD1yyXb1TOWzVuXiVkRwr0PPqh+A==", - "license": "Apache-2.0", - "dependencies": { - "@material/banner": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/button": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/button/-/button-7.0.0.tgz", - "integrity": "sha512-T1WK03HlOecrufoO4Z/W1dXC/R+VLqrwmBcIVQwqN0TiwUdHDfeCa1TjrqroLn9eJUe73T/O3Abh9b2Nttz77g==", - "license": "Apache-2.0", - "dependencies": { - "@material/button": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/card": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/card/-/card-7.0.0.tgz", - "integrity": "sha512-Nxw8Zg2Zt6RwD6S61rg7TgIQFYV4tRswicMj30XwczshVRsau/Jv82hsx98aH9Xx1vV5DFnEdPt0a5HqxBUsLA==", - "license": "Apache-2.0", - "dependencies": { - "@material/card": "^14.0.0", - "@smui/button": "^7.0.0", - "@smui/common": "^7.0.0", - "@smui/icon-button": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/checkbox": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/checkbox/-/checkbox-7.0.0.tgz", - "integrity": "sha512-cHaO9aWi2Pk9GTBcVmvnF4Cwie+ySqODjCMU3OMmhtUZX3LamneyNMRtNqiKXmpr8sXVE6EK+WUisZrJp5e97g==", - "license": "Apache-2.0", - "dependencies": { - "@material/checkbox": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/chips": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/chips/-/chips-7.0.0.tgz", - "integrity": "sha512-jYjUJkp0W6+Yr2J2er6ESvlS2BEBsk2CwM1XDh6465B0oVNJh5QQgAskjIxjjnfRbV+TyKYVJ572GMYoLPZuOA==", - "license": "Apache-2.0", - "dependencies": { - "@material/chips": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/rtl": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/circular-progress": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/circular-progress/-/circular-progress-7.0.0.tgz", - "integrity": "sha512-n6YpMM5VRJ8qBNdbDsNz32CcXESZdNfgsgBNO8Nx7Gq3S0vIglkzHryRQCnKsQA1WN9Tm4lyU0Rafwfb3oWn3w==", - "license": "Apache-2.0", - "dependencies": { - "@material/circular-progress": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/common": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/common/-/common-7.0.0.tgz", - "integrity": "sha512-/JUf25KMIDLFNfiuMSMs2g7dEZZFUnDJDxpbT3FlGEY/HKoEf0W9GnbmIOzPje1wxW9ajKHN2SIYDPx9so1vnw==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/data-table": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/data-table/-/data-table-7.0.0.tgz", - "integrity": "sha512-ZpAdnovoi5g0ya6oitMfPWttsiF9u2N9WyZtY5BYfxNU3famCmM8Wbtno8pF0sZ5tNueEw1Xn8A1BI+KNKBPyA==", - "license": "Apache-2.0", - "dependencies": { - "@material/data-table": "^14.0.0", - "@material/dom": "^14.0.0", - "@smui/checkbox": "^7.0.0", - "@smui/common": "^7.0.0", - "@smui/icon-button": "^7.0.0", - "@smui/ripple": "^7.0.0", - "@smui/select": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/dialog": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/dialog/-/dialog-7.0.0.tgz", - "integrity": "sha512-F6lxwiGoc9ga0btsnpMWIYXPUXAKS0Ewa3CQH9HUPumqCzImTIlsGWL8fiAP3feVjRK+InRSaryZz0eJFZLo/A==", - "license": "Apache-2.0", - "dependencies": { - "@material/button": "^14.0.0", - "@material/dialog": "^14.0.0", - "@material/dom": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/drawer": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/drawer/-/drawer-7.0.0.tgz", - "integrity": "sha512-FZcbgOtpgJIWyqD0N00R3y9rxwcmIVXwYipZ7KnNhHp4W/Ywv3wUuyeflVziN2c2KbnmkruChibyVvdlro9ugQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/drawer": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/fab": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/fab/-/fab-7.0.0.tgz", - "integrity": "sha512-d8jPe9fyTMD0YMBS48RYFb3JITGonHvD7DRlf4iOYzkBCpv7amGbzJSJn88TWx/665FJugQfUKxD/IkooHVthQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/fab": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/floating-label": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/floating-label/-/floating-label-7.0.0.tgz", - "integrity": "sha512-XHm8fNURqQqPnu+TFPIWuD7NtQIfdT2tv0eiyU+1g9Df1pH9pAVWixTiBaj5HKeky1u99+vFJNBslQS2qTxGhg==", - "license": "Apache-2.0", - "dependencies": { - "@material/floating-label": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/form-field": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/form-field/-/form-field-7.0.0.tgz", - "integrity": "sha512-qOs36mqDAjmCSmcgMsuJvSXdub6mk0XWofzhlryHwr/dBcih26RSASpsglUU0qlHer1NOd497JboHIViZib5yQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/form-field": "^14.0.0", - "@material/rtl": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/icon-button": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/icon-button/-/icon-button-7.0.0.tgz", - "integrity": "sha512-SlcmGTW027X44O2QERC5fCZB1zLDsy9k5Cn+ohM0woSFNV3pIlrWtCyYFr1ariRiT8NTF1ScMdW70y8YgK9ikA==", - "license": "Apache-2.0", - "dependencies": { - "@material/density": "^14.0.0", - "@material/icon-button": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/image-list": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/image-list/-/image-list-7.0.0.tgz", - "integrity": "sha512-v/GH8GobmRWoyRtxKjSqrdOrw9yzVkofbprkGWtTKu/0tLeIk2WaZpCYcUwyuu2eTQtfZJxKtB5roI9UvVDX4Q==", - "license": "Apache-2.0", - "dependencies": { - "@material/image-list": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/layout-grid": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/layout-grid/-/layout-grid-7.0.0.tgz", - "integrity": "sha512-LF4Lh8FwZa9swh5/Yxf1aTiqBTrq3txmExTsCl9UcbGpOLXGS5ITXDJO9YWePj9YtT/GlNHEcGishP1gChkCTQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/layout-grid": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/line-ripple": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/line-ripple/-/line-ripple-7.0.0.tgz", - "integrity": "sha512-Mm5B8xci4SglMehp404veVu3zGlZVHViiYsNPpypaG+4aOrK0dVgBrd1YgqyXHKFSHOama/Olwe2E2YbkQvxlw==", - "license": "Apache-2.0", - "dependencies": { - "@material/line-ripple": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/linear-progress": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/linear-progress/-/linear-progress-7.0.0.tgz", - "integrity": "sha512-G5CsHAKqancHpw4X075qA8fOdS94Mu8Pbi74kEWQHcsJrT+6ef4uBt+eFOK0BZ61znxbcS8v+Hp9Zztn+wS5DA==", - "license": "Apache-2.0", - "dependencies": { - "@material/linear-progress": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/list": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/list/-/list-7.0.0.tgz", - "integrity": "sha512-kZnpfbkFtIs9vZCr2xxDTO5Dcdj6WAtGa+82sN5zGg6hC5q8KMGKOxNIFdTz2hw5a6Bg//Zg2mK5AB4tVM4LBw==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/list": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/menu": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/menu/-/menu-7.0.0.tgz", - "integrity": "sha512-3l24nZlW7WdNdVy8i/UT71UPZXdql+0GkRpI5Wy/GsZc2pOCSd1IWWkoiEqX00c5uTCfqX2S25mnKiOdvzKBwg==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/menu": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/list": "^7.0.0", - "@smui/menu-surface": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/menu-surface": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/menu-surface/-/menu-surface-7.0.0.tgz", - "integrity": "sha512-KnPgCjtk3Gqo3fU+wOz7vKr1kRkCeOlrn2i4yAdW2cWoL/3HAmDWrHy/mpnG+vr2RfLK6Tw30V9L5PpFhKgrSQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/animation": "^14.0.0", - "@material/menu-surface": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/notched-outline": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/notched-outline/-/notched-outline-7.0.0.tgz", - "integrity": "sha512-pE0yWIO0K9wrU+LIbBXCBYJUo1+qq5jiLTFVJEoyLqB7jtl438sylXpti9aLyB9DG5V8rbkNCkyNxyq4ZVhmlw==", - "license": "Apache-2.0", - "dependencies": { - "@material/notched-outline": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/floating-label": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/paper": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/paper/-/paper-7.0.0.tgz", - "integrity": "sha512-yRwO7LHciZUjL2odN3Z4zd8RDGVVVMRVBpO8zZRGWa9pvk9hQoQI5gJIZLf9bjifZvwe2TN2hls8dsG8Awjfkw==", - "license": "Apache-2.0", - "dependencies": { - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/shape": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/typography": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/radio": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/radio/-/radio-7.0.0.tgz", - "integrity": "sha512-M83PDtiFDw7p4XHg0061m8RbAVb8EyZqm2bmSTu8PIeEFtmmggooh+NtIRmrroQoDF2zDdr2896j1vmIz64jLQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/radio": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/ripple": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/ripple/-/ripple-7.0.0.tgz", - "integrity": "sha512-N42jqgLOleOj3fU1BnkTPbjtWpisp8x9oUgF32SDkVh48ih8J8+/xQ1W5g28WrNDErjSu9G4DTcYN6BJYOng3Q==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/ripple": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/segmented-button": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/segmented-button/-/segmented-button-7.0.0.tgz", - "integrity": "sha512-Bx4A0dzCvdBMxeKzOh9LuCJHYBGQlwP0fXCp+gt21xE4vc9zDul3v8kjsVRY1tvrk2UA7AmPEP8nVBz0qOH71Q==", - "license": "Apache-2.0", - "dependencies": { - "@material/base": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/segmented-button": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/select": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/select/-/select-7.0.0.tgz", - "integrity": "sha512-ARHVovPy7Tu4b4C9O8Pr9pqI+gGXLH4fxG7NkXXpy8OZ92FDBlAB1ztRQa6XxykIRTCHhmuWOogEw6UimNvJ+w==", - "license": "Apache-2.0", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/select": "^14.0.0", - "@material/theme": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/floating-label": "^7.0.0", - "@smui/line-ripple": "^7.0.0", - "@smui/list": "^7.0.0", - "@smui/menu": "^7.0.0", - "@smui/menu-surface": "^7.0.0", - "@smui/notched-outline": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/slider": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/slider/-/slider-7.0.0.tgz", - "integrity": "sha512-ItVupldtzAtPvHGrflB/8LIn1UoFD+Ow2XZuMJXPjqTxS2vsJI3RVKDqsN5oY5xFabiLzTEaUJeukv1/ZQlDrQ==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/slider": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/snackbar": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/snackbar/-/snackbar-7.0.0.tgz", - "integrity": "sha512-01XcqJJhpOx1szlWfV5Eex+cZOPP4HkFz7MefsC33NOS68eWoNEre6rEsKw6XEeYnbKHv6wNbyowX4c8eRKncg==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/snackbar": "^14.0.0", - "@smui/button": "^7.0.0", - "@smui/common": "^7.0.0", - "@smui/icon-button": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/switch": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/switch/-/switch-7.0.0.tgz", - "integrity": "sha512-LeY1aIMvbJOOmex5o/M7jOX0y6K/1Xa2u6myju30mOy595ibxMGRfuNSdePHBBrR79QbsEow/otltSA1vspw2Q==", - "license": "Apache-2.0", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/switch": "^14.0.0", - "@material/theme": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/tab": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/tab/-/tab-7.0.0.tgz", - "integrity": "sha512-FlOwRO4vBlAH5wm4EFrOPiKtsGkoMbyWqtMyGwBLrGQUTPVUUrFFnHD0J2QHztKKiaIJme+GqrD1bQA9whfKkA==", - "license": "Apache-2.0", - "dependencies": { - "@material/tab": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/ripple": "^7.0.0", - "@smui/tab-indicator": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/tab-bar": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/tab-bar/-/tab-bar-7.0.0.tgz", - "integrity": "sha512-ycAuoGAS2YaV541vDib69CAswNQ+yMRFnloagRcUhyJ4t1eypLDXnba29r1oiEkhwdunDMcH7UCbeyrZUIBJFg==", - "license": "Apache-2.0", - "dependencies": { - "@material/tab-bar": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/tab": "^7.0.0", - "@smui/tab-scroller": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/tab-indicator": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/tab-indicator/-/tab-indicator-7.0.0.tgz", - "integrity": "sha512-wyLpiljJwF1X9ZvCsVFIzqVkbxFF1wyxvAnBem74y6gKlyKa1F5oUm+bwNI/zB7lyWkTjsvWKlWsrzqDdOuFPg==", - "license": "Apache-2.0", - "dependencies": { - "@material/tab-indicator": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/tab-scroller": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/tab-scroller/-/tab-scroller-7.0.0.tgz", - "integrity": "sha512-4smThaPvy3sVj4/Ezgo8er7BqWmnnemmpuWPcNpmt3+hckUhKwVoqtoa9HUdRZClDbyegABvhwbTjIkR39IM+Q==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/tab-scroller": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/textfield": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/textfield/-/textfield-7.0.0.tgz", - "integrity": "sha512-3p/pipFrj6xuA6YUhIEHf4fxInMENI3EFHVYeF1wU0vERSojtqBw+NnAscgv/PHKv4X6nFY3ZvD/9cVf7S4frA==", - "license": "Apache-2.0", - "dependencies": { - "@material/dom": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/textfield": "^14.0.0", - "@smui/common": "^7.0.0", - "@smui/floating-label": "^7.0.0", - "@smui/line-ripple": "^7.0.0", - "@smui/notched-outline": "^7.0.0", - "@smui/ripple": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/tooltip": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/tooltip/-/tooltip-7.0.0.tgz", - "integrity": "sha512-19fzYEerNnpPnpuf7uq6XrcXrE9x3n1Y3Z51+q1U/PmWGCj72n+QazhQNpxFM308DKpmsXdXXUpCP+HzLbB2Yg==", - "license": "Apache-2.0", - "dependencies": { - "@material/tooltip": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/top-app-bar": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/top-app-bar/-/top-app-bar-7.0.0.tgz", - "integrity": "sha512-BYqItdD81VfI9stSC/lx95qc3ovGI+l+9yHjH+CgqQg3teH9vzMuYx4RzBs87tu7kfutNAepHT297glMvPqqsA==", - "license": "Apache-2.0", - "dependencies": { - "@material/feature-targeting": "^14.0.0", - "@material/top-app-bar": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@smui/touch-target": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@smui/touch-target/-/touch-target-7.0.0.tgz", - "integrity": "sha512-UqhkId+8jWaQxUnO2b613jO5MCAHrxWZEY9GLbafw3kFRNT27BLCRSiMXWiBpm422orGu7K426KlfuYgLsEdZA==", - "license": "Apache-2.0", - "dependencies": { - "@material/touch-target": "^14.0.0", - "@smui/common": "^7.0.0", - "svelte2tsx": "^0.7.8" - } - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/chart.js": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.5.tgz", - "integrity": "sha512-CVVjg1RYTJV9OCC8WeJPMx8gsV8K6WIyIEQUE3ui4AR9Hfgls9URri6Ja3hyMVBbTF8Q2KFa19PE815gWcWhng==", - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/console-clear": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz", - "integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/dedent-js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", - "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-port": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", - "integrity": "sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/livereload": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", - "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.0", - "livereload-js": "^3.3.1", - "opts": ">= 1.2.0", - "ws": "^7.4.3" - }, - "bin": { - "livereload": "bin/livereload.js" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/livereload-js": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", - "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", - "dev": true, - "license": "MIT" - }, - "node_modules/local-access": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz", - "integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/opts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", - "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/rollup": { - "version": "3.29.5", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", - "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=14.18.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-css-only": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-css-only/-/rollup-plugin-css-only-4.5.2.tgz", - "integrity": "sha512-7rj9+jB17Pz8LNcPgtMUb16JcgD8lxQMK9HcGfAVhMK3na/WXes3oGIo5QsrQQVqtgAU6q6KnQNXJrYunaUIQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "5" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "rollup": "<5" - } - }, - "node_modules/rollup-plugin-livereload": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.5.tgz", - "integrity": "sha512-vqQZ/UQowTW7VoiKEM5ouNW90wE5/GZLfdWuR0ELxyKOJUIaj+uismPZZaICU4DnWPVjnpCDDxEqwU7pcKY/PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "livereload": "^0.9.1" - }, - "engines": { - "node": ">=8.3" - } - }, - "node_modules/rollup-plugin-svelte": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.2.tgz", - "integrity": "sha512-hgnIblTRewaBEVQD6N0Q43o+y6q1TmDRhBjaEzQCi50bs8TXqjc+d1zFZyE8tsfgcfNHZQzclh4RxlFUB85H8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^4.1.0", - "resolve.exports": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "rollup": ">=2.0.0", - "svelte": ">=3.5.0" - } - }, - "node_modules/rollup-plugin-svelte/node_modules/@rollup/pluginutils": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", - "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "estree-walker": "^2.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/rollup-plugin-svelte/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semiver": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz", - "integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/sirv-cli": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-2.0.2.tgz", - "integrity": "sha512-OtSJDwxsF1NWHc7ps3Sa0s+dPtP15iQNJzfKVz+MxkEo3z72mCD+yu30ct79rPr0CaV1HXSOBp+MIY5uIhHZ1A==", - "license": "MIT", - "dependencies": { - "console-clear": "^1.1.0", - "get-port": "^3.2.0", - "kleur": "^4.1.4", - "local-access": "^1.0.1", - "sade": "^1.6.0", - "semiver": "^1.0.0", - "sirv": "^2.0.0", - "tinydate": "^1.0.0" - }, - "bin": { - "sirv": "bin.js" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/smob": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", - "dev": true, - "license": "MIT" - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svelte": { - "version": "3.59.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", - "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/svelte-material-ui": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/svelte-material-ui/-/svelte-material-ui-7.0.0.tgz", - "integrity": "sha512-j8vT8zX8ekQ8MNBAvU9q/okQrXCYrJSxoDH06PQNCYrbNrTYzqKa+Fb9KSEGTaqs4PXi9aqjn687ykCiGqbPmQ==", - "license": "Apache-2.0", - "dependencies": { - "@smui-extra/accordion": "^7.0.0", - "@smui-extra/autocomplete": "^7.0.0", - "@smui-extra/badge": "^7.0.0", - "@smui-extra/bottom-app-bar": "^7.0.0", - "@smui/banner": "^7.0.0", - "@smui/button": "^7.0.0", - "@smui/card": "^7.0.0", - "@smui/checkbox": "^7.0.0", - "@smui/chips": "^7.0.0", - "@smui/circular-progress": "^7.0.0", - "@smui/common": "^7.0.0", - "@smui/data-table": "^7.0.0", - "@smui/dialog": "^7.0.0", - "@smui/drawer": "^7.0.0", - "@smui/fab": "^7.0.0", - "@smui/floating-label": "^7.0.0", - "@smui/form-field": "^7.0.0", - "@smui/icon-button": "^7.0.0", - "@smui/image-list": "^7.0.0", - "@smui/layout-grid": "^7.0.0", - "@smui/line-ripple": "^7.0.0", - "@smui/linear-progress": "^7.0.0", - "@smui/list": "^7.0.0", - "@smui/menu": "^7.0.0", - "@smui/menu-surface": "^7.0.0", - "@smui/notched-outline": "^7.0.0", - "@smui/paper": "^7.0.0", - "@smui/radio": "^7.0.0", - "@smui/ripple": "^7.0.0", - "@smui/segmented-button": "^7.0.0", - "@smui/select": "^7.0.0", - "@smui/slider": "^7.0.0", - "@smui/snackbar": "^7.0.0", - "@smui/switch": "^7.0.0", - "@smui/tab": "^7.0.0", - "@smui/tab-bar": "^7.0.0", - "@smui/tab-indicator": "^7.0.0", - "@smui/tab-scroller": "^7.0.0", - "@smui/textfield": "^7.0.0", - "@smui/tooltip": "^7.0.0", - "@smui/top-app-bar": "^7.0.0", - "@smui/touch-target": "^7.0.0" - } - }, - "node_modules/svelte2tsx": { - "version": "0.7.22", - "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.22.tgz", - "integrity": "sha512-hf55ujq17ufVpDQlJzaQfRr9EjlLIwGmFlpKq4uYrQAQFw/99q1OcVYyBT6568iJySgBUY9PdccURrORmfetmQ==", - "license": "MIT", - "dependencies": { - "dedent-js": "^1.0.1", - "pascal-case": "^3.1.1" - }, - "peerDependencies": { - "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", - "typescript": "^4.9.4 || ^5.0.0" - } - }, - "node_modules/terser": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", - "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tinydate": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", - "integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/webserver/package.json b/webserver/package.json deleted file mode 100644 index 72d11b1..0000000 --- a/webserver/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "svelte-app", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "rollup -c", - "dev": "rollup -c -w", - "start": "sirv public --no-clear" - }, - "devDependencies": { - "@mdi/js": "^7.4.47", - "@rollup/plugin-commonjs": "^24.0.0", - "@rollup/plugin-node-resolve": "^15.0.0", - "@rollup/plugin-terser": "^0.4.0", - "@smui/textfield": "^7.0.0", - "rollup": "^3.15.0", - "rollup-plugin-css-only": "^4.3.0", - "rollup-plugin-livereload": "^2.0.0", - "rollup-plugin-svelte": "^7.1.2", - "svelte": "^3.55.0" - }, - "dependencies": { - "@smui/button": "^7.0.0", - "@smui/dialog": "^7.0.0", - "@smui/slider": "^7.0.0", - "chart.js": "^4.4.5", - "sirv-cli": "^2.0.0", - "svelte-material-ui": "^7.0.0" - } -} diff --git a/webserver/public/favicon.png b/webserver/public/favicon.png deleted file mode 100644 index 7e6f5eb5a2f1f1c882d265cf479de25caa925645..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3127 zcmV-749N3|P)i z7)}s4L53SJCkR}iVi00SFk;`MXX*#X*kkwKs@nFGS}c;=?XFjU|G$3t^5sjIVS2G+ zw)WGF83CpoGXhLGW(1gW%uV|X7>1P6VhCX=Ux)Lb!*DZ%@I3!{Gsf7d?gtIQ%nQiK z3%(LUSkBji;C5Rfgd6$VsF@H`Pk@xtY6t<>FNR-pD}=C~$?)9pdm3XZ36N5PNWYjb z$xd$yNQR9N!dfj-Vd@BwQo^FIIWPPmT&sZyQ$v81(sCBV=PGy{0wltEjB%~h157*t zvbe_!{=I_783x!0t1-r#-d{Y?ae$Q4N_Nd^Ui^@y(%)Gjou6y<3^XJdu{rmUf-Me?)zZ>9OR&6U5H*cK; z$gUlB{g0O4gN0sLSO|Of?hU(l?;h(jA3uH!Z{EBKuV23ouU@^Y6#%v+QG;>e*E}%?wlu-NT4DG zs)z)7WbLr)vGAu(ohrKc^em@OpO&f~6_>E61n_e0_V3@{U3^O;j{`^mNCJUj_>;7v zsMs6Hu3g7+@v+lSo;=yTYFqq}jZmQ-BK8K{C4kqi_i*jBaQE(Au0607V-zKeT;EPg zX(`vrn=L+e74+-Tqeok@_`tDa$G9I|$nTU5H*2V8@y()n*zqM?J1G!-1aX;CfDC9B zTnJ#j_%*n8Qb1)re*Bno7g0RG{Eb;IK14irJYJp$5Z6ac9~b_P?+5t~95~SRG$g?1 znFJ7p$xV&GZ18m~79TGRdfsc-BcX$9yXTR*n)mPD@1~O(_?cT$ZvFPucRmGlq&se0 zKrcUf^k}4hM*biEJOWKzz!qQe;CB_ZtSOO9Owg#lZAc=s65^rb{fZe(TYu_rk!wKkEf}RIt=#Om( zR8mN`DM<^xj~59euMMspBolVN zAPTr8sSDI104orIAdmL$uOXn*6hga1G+0WD0E?UtabxC#VC~vf3|10|phW;yQ3CY8 z2CM=)ErF;xq-YJ5G|um}>*1#E+O_Mu|Nr#qQ&G1P-NMq@f?@*XUcSbV?tX=)ilM-Q zBZP|!Bpv0V;#ojKcpc7$=eqO;#Uy~#?^kNI{vSZfLx&DEt~LTmaKWXcx=joubklI<*Aw z>LtMaQ7DR<1I2LkWvwyu#Rwn~;ezT}_g(@5l3h?W%-a86Y-t#O1PubP+z<%?V5D(U zy57A6{h+{?kOZp7&WKZR+=sznMJ}+Dnpo=C_0%R_x_t~J5T?E_{+))l5v1%52>)d-`iiZyx|5!%M2Fb2dU zW3~MwwpEH9Rhue+k$UIOoo($Ds!NbOyMR36fRHu;*15(YcA7siIZk#%JWz>P!qX1?IUojG&nKR>^gArBt2 zit(ETyZ=@V&7mv_Fi4bABcnwP+jzQuHcfU&BrAV91u-rFvEi7y-KnWsvHH=d2 zgAk(GKm_S8RcTJ>2N3~&Hbwp{Z3NF_Xeh}g4Eke)V&dY{W(3&b1j9t4yK_aYJisZZ{1rcU5- z;eD>K;ndPq&B-8yA_S0F!4ThA&{1{x)H<#?k9a#6Pc6L?V^s0``ynL&D;p(!Nmx`Y zFkHex{4p!Ggm^@DlehW}iHHVi}~u=$&N? z(NEBLQ#UxxAkdW>X9LnqUr#t4Lu0=9L8&o>JsqTtT5|%gb3QA~hr0pED71+iFFr)dZ=Q=E6ng{NE{Z~0)C?deO#?Aj zSDQ$z#TeC2T^|=}6GBo-&$;E{HL3!q3Z-szuf)O=G#zDjin4SSP%o%6+2IT#sLjQa ziyxFFz~LMjWY+_a5H!U6%a<=b7QVP^ z*90a62;bVq{?@)P6^DWd^Yilq4|YTV2Nw!Yu;a1lPI-sxR)rf@Fe5DhDP7FH zZZ%4S*1C30P;|O+jB!1;m|rXT90Sm5*RBbQN`PKu+hDD*S^yE(CdtSfg=z>u$cIj> z - - - - - - Svelte app - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/webserver/rollup.config.js b/webserver/rollup.config.js deleted file mode 100644 index d1d7306..0000000 --- a/webserver/rollup.config.js +++ /dev/null @@ -1,78 +0,0 @@ -import { spawn } from 'child_process'; -import svelte from 'rollup-plugin-svelte'; -import commonjs from '@rollup/plugin-commonjs'; -import terser from '@rollup/plugin-terser'; -import resolve from '@rollup/plugin-node-resolve'; -import livereload from 'rollup-plugin-livereload'; -import css from 'rollup-plugin-css-only'; - -const production = !process.env.ROLLUP_WATCH; - -function serve() { - let server; - - function toExit() { - if (server) server.kill(0); - } - - return { - writeBundle() { - if (server) return; - server = spawn('npm', ['run', 'start', '--', '--dev'], { - stdio: ['ignore', 'inherit', 'inherit'], - shell: true - }); - - process.on('SIGTERM', toExit); - process.on('exit', toExit); - } - }; -} - -export default { - input: 'src/main.js', - output: { - sourcemap: true, - format: 'iife', - name: 'app', - file: 'public/build/bundle.js' - }, - plugins: [ - svelte({ - compilerOptions: { - // enable run-time checks when not in production - dev: !production - } - }), - // we'll extract any component CSS out into - // a separate file - better for performance - css({ output: 'bundle.css' }), - - // If you have external dependencies installed from - // npm, you'll most likely need these plugins. In - // some cases you'll need additional configuration - - // consult the documentation for details: - // https://github.com/rollup/plugins/tree/master/packages/commonjs - resolve({ - browser: true, - dedupe: ['svelte'], - exportConditions: ['svelte'] - }), - commonjs(), - - // In dev mode, call `npm run start` once - // the bundle has been generated - !production && serve(), - - // Watch the `public` directory and refresh the - // browser on changes when not in production - !production && livereload('public'), - - // If we're building for production (npm run build - // instead of npm run dev), minify - production && terser() - ], - watch: { - clearScreen: false - } -}; diff --git a/webserver/scripts/setupTypeScript.js b/webserver/scripts/setupTypeScript.js deleted file mode 100644 index 4385f65..0000000 --- a/webserver/scripts/setupTypeScript.js +++ /dev/null @@ -1,134 +0,0 @@ -// @ts-check - -/** This script modifies the project to support TS code in .svelte files like: - - - - As well as validating the code for CI. - */ - -/** To work on this script: - rm -rf test-template template && git clone sveltejs/template test-template && node scripts/setupTypeScript.js test-template -*/ - -import fs from "fs" -import path from "path" -import { argv } from "process" -import url from 'url'; - -const __filename = url.fileURLToPath(import.meta.url); -const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); -const projectRoot = argv[2] || path.join(__dirname, "..") - -// Add deps to pkg.json -const packageJSON = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8")) -packageJSON.devDependencies = Object.assign(packageJSON.devDependencies, { - "svelte-check": "^3.0.0", - "svelte-preprocess": "^5.0.0", - "@rollup/plugin-typescript": "^11.0.0", - "typescript": "^4.9.0", - "tslib": "^2.5.0", - "@tsconfig/svelte": "^3.0.0" -}) - -// Add script for checking -packageJSON.scripts = Object.assign(packageJSON.scripts, { - "check": "svelte-check" -}) - -// Write the package JSON -fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify(packageJSON, null, " ")) - -// mv src/main.js to main.ts - note, we need to edit rollup.config.js for this too -const beforeMainJSPath = path.join(projectRoot, "src", "main.js") -const afterMainTSPath = path.join(projectRoot, "src", "main.ts") -fs.renameSync(beforeMainJSPath, afterMainTSPath) - -// Switch the app.svelte file to use TS -const appSveltePath = path.join(projectRoot, "src", "App.svelte") -let appFile = fs.readFileSync(appSveltePath, "utf8") -appFile = appFile.replace(" - -
- {#if showStart} - - {:else if showStop} - - {:else if showReset} - - {/if} - - - - - - - - -
diff --git a/webserver/src/components/EventButtons.svelte b/webserver/src/components/EventButtons.svelte deleted file mode 100644 index f55cc1d..0000000 --- a/webserver/src/components/EventButtons.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - -
- {#each events as event} - - {/each} -
diff --git a/webserver/src/components/FanSlider.svelte b/webserver/src/components/FanSlider.svelte deleted file mode 100644 index 97b5b6d..0000000 --- a/webserver/src/components/FanSlider.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
- - - - -
diff --git a/webserver/src/components/HeaterSlider.svelte b/webserver/src/components/HeaterSlider.svelte deleted file mode 100644 index 6fcb4be..0000000 --- a/webserver/src/components/HeaterSlider.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
- - - - -
diff --git a/webserver/src/components/RoastGraph.svelte b/webserver/src/components/RoastGraph.svelte deleted file mode 100644 index 7b25e40..0000000 --- a/webserver/src/components/RoastGraph.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - - diff --git a/webserver/src/components/RoastSettingsDialog.svelte b/webserver/src/components/RoastSettingsDialog.svelte deleted file mode 100644 index eb0a1cf..0000000 --- a/webserver/src/components/RoastSettingsDialog.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - -

Roast Settings

- - - -
diff --git a/webserver/src/components/SettingsDialog.svelte b/webserver/src/components/SettingsDialog.svelte deleted file mode 100644 index 72dae5f..0000000 --- a/webserver/src/components/SettingsDialog.svelte +++ /dev/null @@ -1,13 +0,0 @@ - - - - -

Settings

- - - -
diff --git a/webserver/src/components/TemperatureReadout.svelte b/webserver/src/components/TemperatureReadout.svelte deleted file mode 100644 index 9798915..0000000 --- a/webserver/src/components/TemperatureReadout.svelte +++ /dev/null @@ -1,10 +0,0 @@ - - - -
- - -
diff --git a/webserver/src/main.js b/webserver/src/main.js deleted file mode 100644 index d6cacbb..0000000 --- a/webserver/src/main.js +++ /dev/null @@ -1,10 +0,0 @@ -import App from './App.svelte'; - -const app = new App({ - target: document.body, - props: { - name: 'world' - } -}); - -export default app; \ No newline at end of file diff --git a/webserver/src/store.js b/webserver/src/store.js deleted file mode 100644 index 40b61e1..0000000 --- a/webserver/src/store.js +++ /dev/null @@ -1,47 +0,0 @@ -import { writable } from "svelte/store"; - -export const readings = writable({}); -export const fanPower = writable(0); -export const heaterPower = writable(0); - -export function updateFanPower(value) { - fanPower.set(value); - sendCommand({ FanVal: value }); -} -export function updateHeaterPower(value) { - heaterPower.set(value); - sendCommand({ BurnerVal: value }); -} -export function addEvent(value) { - console.log("got event #{value}"); -} - -const isRoasting = writable(false); -let timerId; -export function startRoast(params) { - isRoasting.set(true) - startReadings() - // isRoasting.set(!isRoasting); - // if (isRoasting.get == false) { - // clearInterval(timerId); - // console.log("Timer stopped"); - // } else { - // startReadings(); - // } -} -export function stopRoast(params) { - isRoasting.set(false) - clearInterval(timerId); -} - -export function resetRoast(params) {} - -function sendCommand(data) { - // WebSocket code to send updated values -} - -export function startReadings() { - timerId = setInterval(() => { - console.log("This runs every 1 second"); - }, 1000); -} From c3c7fbab510c793dde6bafb3f95c872a1214db94 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:29:26 +0200 Subject: [PATCH 028/125] Fix WebSocket polling dropouts with reconnect loop --- miniweb/src/websocket.ts | 119 +++++++++++++++++++++++++-------------- 1 file changed, 76 insertions(+), 43 deletions(-) diff --git a/miniweb/src/websocket.ts b/miniweb/src/websocket.ts index 7dcf2d9..2f93839 100644 --- a/miniweb/src/websocket.ts +++ b/miniweb/src/websocket.ts @@ -1,58 +1,91 @@ import van from "vanjs-core"; import { YaegerMessage } from "./model.ts"; -// State variables export const connectionStatus = van.state("Disconnected"); export const lastMessage = van.state(null); export const lastUpdate = van.state(null); -// Initialize WebSocket -export const socket = new WebSocket("ws://" + location.host + "/ws"); +const WS_PATH = "/ws"; +const DATA_REQUEST_INTERVAL_MS = 1000; +const RECONNECT_DELAY_MS = 2000; -// WebSocket message handling -socket.onmessage = (event) => { - console.log("WebSocket message received:", event.data); +let socket: WebSocket | null = null; +let requestTimerId: number | null = null; +let reconnectTimerId: number | null = null; + +function stopPolling() { + if (requestTimerId != null) { + window.clearInterval(requestTimerId); + requestTimerId = null; + } +} + +function scheduleReconnect() { + if (reconnectTimerId != null) { + return; + } + + reconnectTimerId = window.setTimeout(() => { + reconnectTimerId = null; + connectWebSocket(); + }, RECONNECT_DELAY_MS); +} + +function sendGetData() { + if (socket?.readyState !== WebSocket.OPEN) { + return; + } + + socket.send( + JSON.stringify({ + id: 1, + command: "getData", + }), + ); +} + +function handleMessage(event: MessageEvent) { try { - const data = JSON.parse(event.data); - const message: YaegerMessage = data.data; - if (message != undefined) { + const parsed = JSON.parse(event.data); + const message: YaegerMessage | undefined = parsed.data; + + if (message) { lastMessage.val = message; lastUpdate.val = new Date(); } } catch (error) { console.error("Error parsing WebSocket message:", error); } -}; - -socket.onopen = () => { - console.log("WebSocket connection established"); - connectionStatus.val = "Connected"; - startPeriodicWebSocketMessages(1000); -}; - -function startPeriodicWebSocketMessages(interval: number) { - if (socket.readyState === WebSocket.OPEN) { - const timerId = setInterval(() => { - const cmd = JSON.stringify({ - id: 1, - command: "getData", - }); - socket.send(cmd); - }, interval); - - // Clear timer on WebSocket close - socket.onclose = () => { - console.log("WebSocket connection closed"); - connectionStatus.val = "Disconnected"; - clearInterval(timerId); - console.log("Timer stopped due to WebSocket closure."); - }; - socket.onerror = (error) => { - console.error("WebSocket error:", error); - clearInterval(timerId); - connectionStatus.val = "Error"; - }; - } else { - console.warn("WebSocket is not open. Timer will not start."); - } -} +} + +function connectWebSocket() { + stopPolling(); + + const wsProtocol = location.protocol === "https:" ? "wss" : "ws"; + socket = new WebSocket(`${wsProtocol}://${location.host}${WS_PATH}`); + + socket.onopen = () => { + connectionStatus.val = "Connected"; + sendGetData(); + requestTimerId = window.setInterval(sendGetData, DATA_REQUEST_INTERVAL_MS); + }; + + socket.onmessage = handleMessage; + + socket.onclose = () => { + connectionStatus.val = "Disconnected"; + stopPolling(); + scheduleReconnect(); + }; + + socket.onerror = (error) => { + console.error("WebSocket error:", error); + connectionStatus.val = "Error"; + stopPolling(); + socket?.close(); + }; +} + +connectWebSocket(); + +export { socket }; From f917386abbd17e581dd824f416fadde31f81f02e Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:34:18 +0200 Subject: [PATCH 029/125] Fix OTA mode detection for filesystem uploads --- scripts/elegantota_upload.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py index 4924fa9..0bd59b4 100644 --- a/scripts/elegantota_upload.py +++ b/scripts/elegantota_upload.py @@ -143,6 +143,17 @@ def _normalise_base_url(custom_upload_url: str) -> tuple[str, str | None]: return base_url, _build_auth_header(username, password) +def _detect_ota_mode(env, filename: str) -> str: # noqa: ANN001 (PlatformIO callback data) + target_names = set(env.Get("COMMAND_LINE_TARGETS") or []) + if "uploadfs" in target_names or "buildfs" in target_names: + return "fs" + + lowered = filename.lower() + if "littlefs" in lowered or "spiffs" in lowered or "fatfs" in lowered: + return "fs" + + return "fr" + def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signature) firmware_path = str(source[0]) @@ -154,7 +165,7 @@ def on_upload(source, target, env): # noqa: ANN001 (PlatformIO callback signatu firmware_md5 = hashlib.md5(firmware_data).hexdigest() filename = os.path.basename(firmware_path) - mode = "fs" if filename in ("spiffs.bin", "littlefs.bin") else "fr" + mode = _detect_ota_mode(env, filename) start_url = f"{base_url}/ota/start?mode={mode}&hash={firmware_md5}" upload_url = f"{base_url}/ota/upload" From a47e608a7707d5beb792921a42a1aa837fc8bf52 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:37:03 +0200 Subject: [PATCH 030/125] Fix ElegantOTA target detection for SCons environment --- scripts/elegantota_upload.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/elegantota_upload.py b/scripts/elegantota_upload.py index 0bd59b4..82daec4 100644 --- a/scripts/elegantota_upload.py +++ b/scripts/elegantota_upload.py @@ -144,7 +144,9 @@ def _normalise_base_url(custom_upload_url: str) -> tuple[str, str | None]: return base_url, _build_auth_header(username, password) def _detect_ota_mode(env, filename: str) -> str: # noqa: ANN001 (PlatformIO callback data) - target_names = set(env.Get("COMMAND_LINE_TARGETS") or []) + get_value = getattr(env, "get", None) + raw_targets = get_value("COMMAND_LINE_TARGETS") if callable(get_value) else None + target_names = set(raw_targets or []) if "uploadfs" in target_names or "buildfs" in target_names: return "fs" From 2897dd29e539be018a2de84747fdf3e027239f54 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:43:45 +0200 Subject: [PATCH 031/125] Fix sensor sampling interval overflow on ESP32 --- src/sensors.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sensors.cpp b/src/sensors.cpp index de9840c..afd286a 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -20,7 +20,7 @@ Adafruit_MAX31855 tcExhaust(MAX1CLK, MAX1CS, MAX1DO); Adafruit_MAX31855 tcBeans(MAX2CLK, MAX2CS, MAX2DO); const uint8_t kMovingAverageWindowSize = 10; -const uint8_t kSamplingWindowDuration = 400; +const unsigned long kSamplingWindowDurationMs = 400; MovingAverageFilter exhaustFilter(kMovingAverageWindowSize); MovingAverageFilter beansFilter(kMovingAverageWindowSize); @@ -50,8 +50,8 @@ void startSensors() { } void takeReadings() { - float dt = (millis() - lastReadTime); - if (dt < kSamplingWindowDuration) { + unsigned long dt = millis() - lastReadTime; + if (dt < kSamplingWindowDurationMs) { return; } if (xSemaphoreTakeRecursive(mtx, portMAX_DELAY) == pdTRUE) { From d36ce1e7d9c1592b79d4b9dff7b1549d0ec24013 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:51:02 +0200 Subject: [PATCH 032/125] Reduce logging pressure that stalls BT/ET updates --- src/CommandLoop.cpp | 2 ++ src/logging.cpp | 19 +++++++++++++++---- src/sensors.cpp | 6 ++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 73727ca..7ef2785 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -267,7 +267,9 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, String response; response.reserve(measureJson(doc) + 1); serializeJson(doc, response); +#ifdef DEBUG log(response.c_str()); +#endif client->text(response); } break; default: diff --git a/src/logging.cpp b/src/logging.cpp index 563f8e7..1ba94f5 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -1,15 +1,22 @@ #include "logging.h" #include +namespace { +constexpr bool kEnableWebSerialLogging = false; +} void recvMsg(uint8_t *data, size_t len){ - WebSerial.println("Received Data..."); + if (kEnableWebSerialLogging) { + WebSerial.println("Received Data..."); + } // TODO: can just map to char String d = ""; for(int i=0; i < len; i++){ d += char(data[i]); } - WebSerial.println(d); + if (kEnableWebSerialLogging) { + WebSerial.println(d); + } } void setupLogging(AsyncWebServer *server) { @@ -19,7 +26,9 @@ void setupLogging(AsyncWebServer *server) { void log(const char *message) { Serial.println(message); - WebSerial.println(message); + if (kEnableWebSerialLogging) { + WebSerial.println(message); + } } void logf(const char *format, ...) { @@ -28,6 +37,8 @@ void logf(const char *format, ...) { va_start(args, format); vsnprintf(buf, sizeof(buf), format, args); va_end(args); - WebSerial.print(buf); + if (kEnableWebSerialLogging) { + WebSerial.print(buf); + } Serial.print(buf); } diff --git a/src/sensors.cpp b/src/sensors.cpp index afd286a..906f716 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -59,7 +59,9 @@ void takeReadings() { takeBTReadings(dt); lastReadTime = millis(); float internal = tcExhaust.readInternal(); +#ifdef DEBUG logf("internal: %.2f\n", internal); +#endif readings[2] = internal; xSemaphoreGiveRecursive(mtx); } @@ -78,7 +80,9 @@ void takeETReadings(float dt) { log("FAULT: Thermocouple is short-circuited to VCC."); return; } +#ifdef DEBUG logf("Exhaust Temp: %.2f\n", exhaustTemp); +#endif readings[0] = exhaustFilter.process(exhaustTemp); } @@ -95,7 +99,9 @@ void takeBTReadings(float dt) { log("FAULT: Thermocouple is short-circuited to VCC."); return; } +#ifdef DEBUG logf("Bean Temp: %.2f\n", beanTemp); +#endif readings[1] = beansFilter.process(beanTemp); } From a0e7ba0e4644645b3cfff2b369f10dec7108f64f Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 11:57:38 +0200 Subject: [PATCH 033/125] Add log diagnostics page and harden sensor read path --- miniweb/src/logs.ts | 148 +++++++++++++++++++++++++++++++++++++++++++ miniweb/src/main.ts | 14 ++++ miniweb/src/model.ts | 2 + src/CommandLoop.cpp | 10 +-- src/api.cpp | 48 ++++++++++++++ src/logging.cpp | 24 +++++++ src/logging.h | 3 + src/main.cpp | 1 + src/sensors.cpp | 12 +++- src/sensors.h | 3 +- 10 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 miniweb/src/logs.ts diff --git a/miniweb/src/logs.ts b/miniweb/src/logs.ts new file mode 100644 index 0000000..338bafd --- /dev/null +++ b/miniweb/src/logs.ts @@ -0,0 +1,148 @@ +import van from "vanjs-core"; +import { getBasicAuthHeaderValue } from "./auth"; + +const { div, h1, button, p, textarea, input } = van.tags; + +const logText = van.state(""); +const logError = van.state(""); +const csrfToken = van.state(""); +let refreshTimerId: number | null = null; + +async function fetchCsrfToken() { + const response = await fetch(`http://${location.host}/api/info`); + if (!response.ok) { + throw new Error(`Failed to load CSRF token: ${response.status}`); + } + const info = (await response.json()) as { csrfToken?: string }; + csrfToken.val = info.csrfToken || ""; +} + +async function refreshLogs() { + try { + logError.val = ""; + const response = await fetch(`http://${location.host}/api/logs`); + if (!response.ok) { + throw new Error(`Failed to fetch logs: ${response.status}`); + } + logText.val = await response.text(); + } catch (error) { + logError.val = error instanceof Error ? error.message : "Unknown log error"; + } +} + +async function clearLogs() { + try { + if (!csrfToken.val) { + await fetchCsrfToken(); + } + const response = await fetch(`http://${location.host}/api/logs`, { + method: "DELETE", + headers: { + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": csrfToken.val, + }, + }); + if (!response.ok) { + throw new Error(`Clear failed: ${response.status}`); + } + await refreshLogs(); + } catch (error) { + logError.val = error instanceof Error ? error.message : "Unknown clear error"; + } +} + +function downloadLogs() { + const blob = new Blob([logText.val], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `yaeger-logs-${new Date().toISOString()}.txt`; + a.click(); + URL.revokeObjectURL(url); +} + +async function uploadLogFile(file: File) { + try { + if (!csrfToken.val) { + await fetchCsrfToken(); + } + const body = await file.text(); + const response = await fetch(`http://${location.host}/api/logs/upload`, { + method: "POST", + headers: { + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": csrfToken.val, + "Content-Type": "text/plain", + }, + body, + }); + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + await refreshLogs(); + } catch (error) { + logError.val = error instanceof Error ? error.message : "Unknown upload error"; + } +} + +export const logsApp = () => { + void fetchCsrfToken(); + void refreshLogs(); + + if (refreshTimerId != null) { + window.clearInterval(refreshTimerId); + } + refreshTimerId = window.setInterval(() => { + void refreshLogs(); + }, 2000); + + return div( + { class: "start-page" }, + h1("Yaeger Logs"), + p("Live device logs (auto-refresh every 2s)."), + () => + logError.val + ? p({ style: "color: #b91c1c;" }, "Log error: ", logError.val) + : null, + div( + { class: "section" }, + button({ onclick: () => void refreshLogs() }, "Refresh Logs"), + " ", + button({ onclick: downloadLogs }, "Download Logs"), + " ", + button({ onclick: () => void clearLogs() }, "Clear Device Logs"), + ), + div( + { class: "section" }, + p("Upload a log file into device log history for troubleshooting context."), + input({ + type: "file", + accept: ".txt,.log,application/json,text/plain", + onchange: (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + void uploadLogFile(file); + } + }, + }), + ), + textarea({ + readonly: true, + style: "width: 100%; min-height: 320px; font-family: monospace;", + value: () => logText.val, + }), + div( + { class: "section" }, + button({ + onclick: () => { + if (refreshTimerId != null) { + window.clearInterval(refreshTimerId); + refreshTimerId = null; + } + document.getElementById("app")!.innerHTML = ""; + window.location.href = "/"; + }, + }, "Back"), + ), + ); +}; diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index 71cb812..d5482bd 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -1,6 +1,7 @@ import "./style.css"; import van from "vanjs-core"; import { roastApp } from "./roast"; +import { logsApp } from "./logs"; import { profile, ProfileControl } from "./profiling.ts"; import { PIDController } from "./pid.ts"; import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; @@ -148,6 +149,8 @@ const SensorData = () => "Current Readings:", p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), + p("Sensor sample age: ", () => lastMessage.val?.sampleAgeMs ?? "N/A", " ms"), + p("Sensor status: ", () => (lastMessage.val?.sensorOk ? "OK" : "BUSY/STALE")), p("Last update: ", () => lastUpdate.val?.toString() ?? "N/A"), ); @@ -223,6 +226,17 @@ const startPage = div( }, "Start Roasting", ), + " ", + button( + { + onclick: () => { + document.getElementById("app")!.innerHTML = ""; + van.add(document.getElementById("app")!, logsApp()); + window.history.pushState({}, "", "/logs"); + }, + }, + "Logs", + ), ), ), ); diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index 90798dd..3fa02dc 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -4,6 +4,8 @@ export type YaegerMessage = { ET: number; BT: number; Amb: number; + sampleAgeMs?: number; + sensorOk?: boolean; FanVal: number; BurnerVal: number; id: number; diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 7ef2785..59719b6 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -255,11 +255,13 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, JsonObject dataObj = root["data"].to(); root["id"] = ln_id; float etbt[3]; - getETBTReadings(etbt); + bool gotReading = getETBTReadings(etbt); dataObj["type"] = "status"; - dataObj["ET"] = etbt[0]; - dataObj["BT"] = etbt[1]; - dataObj["Amb"] = etbt[2]; + dataObj["ET"] = gotReading ? etbt[0] : NAN; + dataObj["BT"] = gotReading ? etbt[1] : NAN; + dataObj["Amb"] = gotReading ? etbt[2] : NAN; + dataObj["sampleAgeMs"] = millis() - getLastSensorUpdateMs(); + dataObj["sensorOk"] = gotReading; dataObj["BurnerVal"] = getHeaterPower(); dataObj["FanVal"] = getFanSpeed(); } diff --git a/src/api.cpp b/src/api.cpp index 907c2c4..3607aee 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -89,4 +89,52 @@ void setupApi(AsyncWebServer *server) { serializeJson(doc, body); request->send(200, "application/json", body); }); + + server->on("/api/logs", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(200, "text/plain", getLogBuffer()); + }); + + server->on("/api/logs", HTTP_DELETE, [](AsyncWebServerRequest *request) { + if (!isAuthorizedRequest(request)) { + return; + } + if (!hasValidCsrfHeader(request)) { + request->send(403, "application/json", + "{\"error\":\"missing/invalid csrf header\"}"); + return; + } + clearLogBuffer(); + request->send(200, "application/json", "{\"ok\":true}"); + }); + + server->on( + "/api/logs/upload", HTTP_POST, + [](AsyncWebServerRequest *request) { + // handled in body parser + }, + NULL, + [](AsyncWebServerRequest *request, uint8_t *data, size_t len, + size_t index, size_t total) { + if (index != 0 || len != total) { + request->send(400, "application/json", + "{\"error\":\"chunked body not supported\"}"); + return; + } + if (!isAuthorizedRequest(request)) { + return; + } + if (!hasValidCsrfHeader(request)) { + request->send(403, "application/json", + "{\"error\":\"missing/invalid csrf header\"}"); + return; + } + String uploaded; + uploaded.reserve(len + 16); + uploaded = "[uploaded] "; + for (size_t i = 0; i < len; i++) { + uploaded += char(data[i]); + } + appendExternalLog(uploaded); + request->send(200, "application/json", "{\"ok\":true}"); + }); } diff --git a/src/logging.cpp b/src/logging.cpp index 1ba94f5..a4deb35 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -3,6 +3,22 @@ namespace { constexpr bool kEnableWebSerialLogging = false; +constexpr size_t kLogBufferMaxChars = 32768; +String gLogBuffer; + +void appendToLogBuffer(const char *message) { + if (message == nullptr) { + return; + } + + gLogBuffer += message; + gLogBuffer += "\n"; + + if (gLogBuffer.length() > kLogBufferMaxChars) { + size_t removeCount = gLogBuffer.length() - kLogBufferMaxChars; + gLogBuffer.remove(0, removeCount); + } +} } void recvMsg(uint8_t *data, size_t len){ @@ -26,6 +42,7 @@ void setupLogging(AsyncWebServer *server) { void log(const char *message) { Serial.println(message); + appendToLogBuffer(message); if (kEnableWebSerialLogging) { WebSerial.println(message); } @@ -41,4 +58,11 @@ void logf(const char *format, ...) { WebSerial.print(buf); } Serial.print(buf); + appendToLogBuffer(buf); } + +String getLogBuffer() { return gLogBuffer; } + +void clearLogBuffer() { gLogBuffer = ""; } + +void appendExternalLog(const String &message) { appendToLogBuffer(message.c_str()); } diff --git a/src/logging.h b/src/logging.h index e2d4cd0..d58ff02 100644 --- a/src/logging.h +++ b/src/logging.h @@ -3,3 +3,6 @@ void setupLogging(AsyncWebServer *server); void log(const char *message); void logf(const char *format, ...); +String getLogBuffer(); +void clearLogBuffer(); +void appendExternalLog(const String &message); diff --git a/src/main.cpp b/src/main.cpp index 8a44557..614f293 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -84,6 +84,7 @@ void setup(void) { server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); server.serveStatic("/settings", LittleFS, "/").setDefaultFile("index.html"); server.serveStatic("/editor", LittleFS, "/").setDefaultFile("index.html"); + server.serveStatic("/logs", LittleFS, "/").setDefaultFile("index.html"); String adminSecret = getApiAdminSecret(); ElegantOTA.begin(&server, getApiAdminUsername(), adminSecret.c_str()); // Start ElegantOTA diff --git a/src/sensors.cpp b/src/sensors.cpp index 906f716..5ed1993 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -27,6 +27,7 @@ MovingAverageFilter beansFilter(kMovingAverageWindowSize); /*SimpleKalmanFilter exhaustFilter(80, 80, 3);*/ /*SimpleKalmanFilter beansFilter(80, 80, 3);*/ unsigned long lastReadTime = 0; +unsigned long lastSensorUpdateMs = 0; SemaphoreHandle_t mtx; StaticSemaphore_t mtx_buffer; @@ -63,6 +64,7 @@ void takeReadings() { logf("internal: %.2f\n", internal); #endif readings[2] = internal; + lastSensorUpdateMs = lastReadTime; xSemaphoreGiveRecursive(mtx); } } @@ -105,9 +107,15 @@ void takeBTReadings(float dt) { readings[1] = beansFilter.process(beanTemp); } -void getETBTReadings(float *readingsBuf) { - if (xSemaphoreTakeRecursive(mtx, portMAX_DELAY) == pdTRUE) { +bool getETBTReadings(float *readingsBuf) { + if (xSemaphoreTakeRecursive(mtx, pdMS_TO_TICKS(5)) == pdTRUE) { memcpy(readingsBuf, readings, 3 * sizeof(float)); xSemaphoreGiveRecursive(mtx); + return true; } + return false; +} + +unsigned long getLastSensorUpdateMs() { + return lastSensorUpdateMs; } diff --git a/src/sensors.h b/src/sensors.h index a2e9aa6..85616ef 100644 --- a/src/sensors.h +++ b/src/sensors.h @@ -1,3 +1,4 @@ void startSensors(); void takeReadings(); -void getETBTReadings(float *readings); +bool getETBTReadings(float *readings); +unsigned long getLastSensorUpdateMs(); From 99f7d89215962759d1f62acdb798b08636157b82 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 12:11:08 +0200 Subject: [PATCH 034/125] Debounce thermocouple faults and reduce log spam --- src/config.h | 2 -- src/fan.cpp | 4 ++++ src/sensors.cpp | 58 +++++++++++++++++++++++++++++++++++++------------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/config.h b/src/config.h index fbed2c6..1d52cc9 100644 --- a/src/config.h +++ b/src/config.h @@ -37,5 +37,3 @@ #define DISPLAY_DA 41 #define DISPLAY_CL 42 #endif - -#define DEBUG diff --git a/src/fan.cpp b/src/fan.cpp index 45881de..5d2a5ec 100644 --- a/src/fan.cpp +++ b/src/fan.cpp @@ -39,7 +39,9 @@ void setFanSpeed(int speed) { logf("Fan speed set to %d%%\n", speed); if (xQueueOverwrite(speedQueue, &speed) == pdPASS) { +#ifdef DEBUG logf("Requested fan speed set to %d%%\n", speed); +#endif } } @@ -61,7 +63,9 @@ void rampFanSpeedTask(void *pvParams) { } analogWrite(FAN_PIN, currentFanSpeed * 255 / 100); +#ifdef DEBUG logf("Fan speed updated to %d%%\n", currentFanSpeed); +#endif // Break if the target speed is reached if (currentFanSpeed == desiredSpeed) { diff --git a/src/sensors.cpp b/src/sensors.cpp index 5ed1993..327f1ae 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -21,6 +21,7 @@ Adafruit_MAX31855 tcBeans(MAX2CLK, MAX2CS, MAX2DO); const uint8_t kMovingAverageWindowSize = 10; const unsigned long kSamplingWindowDurationMs = 400; +const uint8_t kFaultDebounceThreshold = 3; MovingAverageFilter exhaustFilter(kMovingAverageWindowSize); MovingAverageFilter beansFilter(kMovingAverageWindowSize); @@ -28,6 +29,8 @@ MovingAverageFilter beansFilter(kMovingAverageWindowSize); /*SimpleKalmanFilter beansFilter(80, 80, 3);*/ unsigned long lastReadTime = 0; unsigned long lastSensorUpdateMs = 0; +uint8_t exhaustFaultCount = 0; +uint8_t beansFaultCount = 0; SemaphoreHandle_t mtx; StaticSemaphore_t mtx_buffer; @@ -72,16 +75,30 @@ void takeReadings() { void takeETReadings(float dt) { float exhaustTemp = tcExhaust.readCelsius(); if (isnan(exhaustTemp)) { + exhaustFaultCount++; + if (exhaustFaultCount < kFaultDebounceThreshold) { + return; + } + uint8_t e = tcExhaust.readError(); - logf("Thermocouple fault(s) detected! %d", e); - if (e & MAX31855_FAULT_OPEN) - log("FAULT: Thermocouple is open - no connections."); - if (e & MAX31855_FAULT_SHORT_GND) - log("FAULT: Thermocouple is short-circuited to GND."); - if (e & MAX31855_FAULT_SHORT_VCC) - log("FAULT: Thermocouple is short-circuited to VCC."); + logf("Exhaust thermocouple fault(s) detected! %d\n", e); + if (e & MAX31855_FAULT_OPEN) { + log("FAULT: Exhaust thermocouple open - no connections."); + } + if (e & MAX31855_FAULT_SHORT_GND) { + log("FAULT: Exhaust thermocouple short-circuited to GND."); + } + if (e & MAX31855_FAULT_SHORT_VCC) { + log("FAULT: Exhaust thermocouple short-circuited to VCC."); + } + + if (e == 0) { + // Sometimes NaN occurs transiently; attempt sensor re-sync. + tcExhaust.begin(); + } return; } + exhaustFaultCount = 0; #ifdef DEBUG logf("Exhaust Temp: %.2f\n", exhaustTemp); #endif @@ -91,16 +108,29 @@ void takeETReadings(float dt) { void takeBTReadings(float dt) { float beanTemp = tcBeans.readCelsius(); if (isnan(beanTemp)) { + beansFaultCount++; + if (beansFaultCount < kFaultDebounceThreshold) { + return; + } + uint8_t e = tcBeans.readError(); - logf("Thermocouple fault(s) detected! %d", e); - if (e & MAX31855_FAULT_OPEN) - log("FAULT: Thermocouple is open - no connections."); - if (e & MAX31855_FAULT_SHORT_GND) - log("FAULT: Thermocouple is short-circuited to GND."); - if (e & MAX31855_FAULT_SHORT_VCC) - log("FAULT: Thermocouple is short-circuited to VCC."); + logf("Bean thermocouple fault(s) detected! %d\n", e); + if (e & MAX31855_FAULT_OPEN) { + log("FAULT: Bean thermocouple open - no connections."); + } + if (e & MAX31855_FAULT_SHORT_GND) { + log("FAULT: Bean thermocouple short-circuited to GND."); + } + if (e & MAX31855_FAULT_SHORT_VCC) { + log("FAULT: Bean thermocouple short-circuited to VCC."); + } + + if (e == 0) { + tcBeans.begin(); + } return; } + beansFaultCount = 0; #ifdef DEBUG logf("Bean Temp: %.2f\n", beanTemp); #endif From 33ff4fa8dd9f59590a819a1c46efb2d9eb172942 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 12:17:49 +0200 Subject: [PATCH 035/125] Re-prime MAX31855 bus pins to recover false short-gnd faults --- src/sensors.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/sensors.cpp b/src/sensors.cpp index 327f1ae..a793ff2 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -39,6 +39,25 @@ float readings[3] = {0, 0, 0}; void takeETReadings(float dt); void takeBTReadings(float dt); +void primeThermocoupleBusPins(); +void reinitializeThermocouples(); + +void primeThermocoupleBusPins() { + pinMode(MAX1CS, OUTPUT); + pinMode(MAX2CS, OUTPUT); + digitalWrite(MAX1CS, HIGH); + digitalWrite(MAX2CS, HIGH); + + // Shared MAX31855 MISO line can float on some clone boards; pull-up helps avoid + // false all-low reads that decode as SHORT_GND faults. + pinMode(MAX1DO, INPUT_PULLUP); +} + +void reinitializeThermocouples() { + primeThermocoupleBusPins(); + tcExhaust.begin(); + tcBeans.begin(); +} void startSensors() { log("Initializing sensors"); @@ -47,6 +66,7 @@ void startSensors() { log("could not create mutex"); } delay(500); // Give the sensors time to settle + primeThermocoupleBusPins(); bool allGood = true; allGood &= tcExhaust.begin(); allGood &= tcBeans.begin(); @@ -96,6 +116,12 @@ void takeETReadings(float dt) { // Sometimes NaN occurs transiently; attempt sensor re-sync. tcExhaust.begin(); } + if ((e & MAX31855_FAULT_SHORT_GND) != 0 && beansFaultCount >= kFaultDebounceThreshold) { + log("Both probes reporting SHORT_GND; reinitializing thermocouple bus"); + reinitializeThermocouples(); + exhaustFaultCount = 0; + beansFaultCount = 0; + } return; } exhaustFaultCount = 0; @@ -128,6 +154,12 @@ void takeBTReadings(float dt) { if (e == 0) { tcBeans.begin(); } + if ((e & MAX31855_FAULT_SHORT_GND) != 0 && exhaustFaultCount >= kFaultDebounceThreshold) { + log("Both probes reporting SHORT_GND; reinitializing thermocouple bus"); + reinitializeThermocouples(); + exhaustFaultCount = 0; + beansFaultCount = 0; + } return; } beansFaultCount = 0; From a4dfdae415669b9b97fc2c26d53e1743602d9897 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 12:34:29 +0200 Subject: [PATCH 036/125] Show ET/BT sensor error codes on LCD display --- src/display.cpp | 36 ++++++++++++++++++++++++++++++++++-- src/display.h | 1 + src/main.cpp | 6 ++++++ src/sensors.cpp | 23 +++++++++++++++++++++++ src/sensors.h | 12 ++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/display.cpp b/src/display.cpp index eecf8af..5e5ab8f 100644 --- a/src/display.cpp +++ b/src/display.cpp @@ -1,17 +1,49 @@ #include "config.h" +#include "sensors.h" #include #include LiquidCrystal_I2C lcd(0x27, 16, 2); void initDisplay() { lcd.init(DISPLAY_DA, DISPLAY_CL); } + +const char *sensorErrToShortText(SensorErrorCode error) { + switch (error) { + case SENSOR_OK: + return "OK"; + case SENSOR_ERR_OPEN: + return "OPEN"; + case SENSOR_ERR_SHORT_GND: + return "SGND"; + case SENSOR_ERR_SHORT_VCC: + return "SVCC"; + default: + return "ERR"; + } +} + void setWifiIP() { lcd.backlight(); lcd.setCursor(0, 0); - lcd.print("Yaeger online"); + lcd.print("Yaeger online "); lcd.setCursor(2, 1); lcd.print("IP:"); - lcd.setCursor(2, 4); + lcd.setCursor(5, 1); lcd.print(WiFi.localIP()); } + +void updateDisplaySensorStatus() { + SensorErrorCode etError = getExhaustSensorError(); + SensorErrorCode btError = getBeanSensorError(); + lcd.setCursor(0, 0); + if (etError == SENSOR_OK && btError == SENSOR_OK) { + lcd.print("Sensors OK "); + } else { + lcd.print("ET:"); + lcd.print(sensorErrToShortText(etError)); + lcd.print(" BT:"); + lcd.print(sensorErrToShortText(btError)); + lcd.print(" "); + } +} diff --git a/src/display.h b/src/display.h index 68ec203..0337e35 100644 --- a/src/display.h +++ b/src/display.h @@ -1,2 +1,3 @@ void initDisplay(); void setWifiIP(); +void updateDisplaySensorStatus(); diff --git a/src/main.cpp b/src/main.cpp index 614f293..3183990 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -31,6 +31,8 @@ AsyncWebSocket ws("/ws"); AsyncWebServer server(80); constexpr unsigned long FAST_TICK_INTERVAL_MS = 10; unsigned long lastFastTickMs = 0; +constexpr unsigned long DISPLAY_REFRESH_INTERVAL_MS = 1000; +unsigned long lastDisplayRefreshMs = 0; void setupSimulation(AsyncWebSocket *ws); void updateSimulation(); @@ -122,6 +124,10 @@ void loop(void) { updateConnectionSafety(&ws); takeReadings(); } + if (now - lastDisplayRefreshMs >= DISPLAY_REFRESH_INTERVAL_MS) { + lastDisplayRefreshMs = now; + updateDisplaySensorStatus(); + } updateHeater(); yield(); } diff --git a/src/sensors.cpp b/src/sensors.cpp index a793ff2..d7c5b6c 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -31,6 +31,8 @@ unsigned long lastReadTime = 0; unsigned long lastSensorUpdateMs = 0; uint8_t exhaustFaultCount = 0; uint8_t beansFaultCount = 0; +SensorErrorCode exhaustSensorError = SENSOR_OK; +SensorErrorCode beanSensorError = SENSOR_OK; SemaphoreHandle_t mtx; StaticSemaphore_t mtx_buffer; @@ -101,6 +103,14 @@ void takeETReadings(float dt) { } uint8_t e = tcExhaust.readError(); + exhaustSensorError = SENSOR_ERR_UNKNOWN; + if ((e & MAX31855_FAULT_OPEN) != 0) { + exhaustSensorError = SENSOR_ERR_OPEN; + } else if ((e & MAX31855_FAULT_SHORT_GND) != 0) { + exhaustSensorError = SENSOR_ERR_SHORT_GND; + } else if ((e & MAX31855_FAULT_SHORT_VCC) != 0) { + exhaustSensorError = SENSOR_ERR_SHORT_VCC; + } logf("Exhaust thermocouple fault(s) detected! %d\n", e); if (e & MAX31855_FAULT_OPEN) { log("FAULT: Exhaust thermocouple open - no connections."); @@ -125,6 +135,7 @@ void takeETReadings(float dt) { return; } exhaustFaultCount = 0; + exhaustSensorError = SENSOR_OK; #ifdef DEBUG logf("Exhaust Temp: %.2f\n", exhaustTemp); #endif @@ -140,6 +151,14 @@ void takeBTReadings(float dt) { } uint8_t e = tcBeans.readError(); + beanSensorError = SENSOR_ERR_UNKNOWN; + if ((e & MAX31855_FAULT_OPEN) != 0) { + beanSensorError = SENSOR_ERR_OPEN; + } else if ((e & MAX31855_FAULT_SHORT_GND) != 0) { + beanSensorError = SENSOR_ERR_SHORT_GND; + } else if ((e & MAX31855_FAULT_SHORT_VCC) != 0) { + beanSensorError = SENSOR_ERR_SHORT_VCC; + } logf("Bean thermocouple fault(s) detected! %d\n", e); if (e & MAX31855_FAULT_OPEN) { log("FAULT: Bean thermocouple open - no connections."); @@ -163,6 +182,7 @@ void takeBTReadings(float dt) { return; } beansFaultCount = 0; + beanSensorError = SENSOR_OK; #ifdef DEBUG logf("Bean Temp: %.2f\n", beanTemp); #endif @@ -181,3 +201,6 @@ bool getETBTReadings(float *readingsBuf) { unsigned long getLastSensorUpdateMs() { return lastSensorUpdateMs; } + +SensorErrorCode getExhaustSensorError() { return exhaustSensorError; } +SensorErrorCode getBeanSensorError() { return beanSensorError; } diff --git a/src/sensors.h b/src/sensors.h index 85616ef..2e330e0 100644 --- a/src/sensors.h +++ b/src/sensors.h @@ -1,4 +1,16 @@ +#include + +enum SensorErrorCode : uint8_t { + SENSOR_OK = 0, + SENSOR_ERR_OPEN = 1, + SENSOR_ERR_SHORT_GND = 2, + SENSOR_ERR_SHORT_VCC = 3, + SENSOR_ERR_UNKNOWN = 255 +}; + void startSensors(); void takeReadings(); bool getETBTReadings(float *readings); unsigned long getLastSensorUpdateMs(); +SensorErrorCode getExhaustSensorError(); +SensorErrorCode getBeanSensorError(); From ae47617daaf84fd16d56785ac7b5a9f3efb85b5c Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 12:40:07 +0200 Subject: [PATCH 037/125] Fix sensor error type compile break and disable LCD by default --- src/config.h | 4 ++++ src/display.cpp | 2 ++ src/display.h | 8 ++++++++ src/sensors.cpp | 1 + 4 files changed, 15 insertions(+) diff --git a/src/config.h b/src/config.h index 1d52cc9..f67aea8 100644 --- a/src/config.h +++ b/src/config.h @@ -37,3 +37,7 @@ #define DISPLAY_DA 41 #define DISPLAY_CL 42 #endif + +#ifndef ENABLE_LCD +#define ENABLE_LCD 0 +#endif diff --git a/src/display.cpp b/src/display.cpp index 5e5ab8f..0591ad0 100644 --- a/src/display.cpp +++ b/src/display.cpp @@ -1,6 +1,7 @@ #include "config.h" #include "sensors.h" +#if ENABLE_LCD #include #include @@ -47,3 +48,4 @@ void updateDisplaySensorStatus() { lcd.print(" "); } } +#endif diff --git a/src/display.h b/src/display.h index 0337e35..430ed4e 100644 --- a/src/display.h +++ b/src/display.h @@ -1,3 +1,11 @@ +#include "config.h" + +#if ENABLE_LCD void initDisplay(); void setWifiIP(); void updateDisplaySensorStatus(); +#else +inline void initDisplay() {} +inline void setWifiIP() {} +inline void updateDisplaySensorStatus() {} +#endif diff --git a/src/sensors.cpp b/src/sensors.cpp index d7c5b6c..22be39e 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -3,6 +3,7 @@ #include "freertos/portmacro.h" #include "freertos/semphr.h" #include "logging.h" +#include "sensors.h" #include #include #include From 1ee3cbf735ff75a3e8e569842f471356d6894b02 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 12:47:52 +0200 Subject: [PATCH 038/125] Make LCD build truly optional and trim default deps --- platformio.ini | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 7cc669a..8eaddf7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -26,6 +26,7 @@ build_flags = -D CORE_DEBUG_LEVEL=3 -D ELEGANTOTA_USE_ASYNC_WEBSERVER=1 ; -D ARDUINO_USB_CDC_ON_BOOT=1 +build_src_filter = +<*> - ; NOTE: Avoid `chain+` / `deep+` with Arduino-ESP32 3.x. ; Those modes can drop framework-internal Network headers from the include path ; (see Network.h / NetworkInterface.h failures in WiFi + AsyncTCP builds). @@ -49,7 +50,6 @@ lib_deps = ayushsharma82/WebSerial ESP32Async/AsyncTCP ESP32Async/ESPAsyncWebServer - https://github.com/iakop/LiquidCrystal_I2C_ESP32 https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- extra_scripts = pre:extra_scripts.py @@ -90,3 +90,23 @@ custom_upload_url = http://yaeger.local/update extra_scripts = ${core.extra_scripts} post:scripts/elegantota_upload.py + +[env:esp32-s3-lcd] +extends = env:esp32-s3 +build_flags = + ${env:esp32-s3.build_flags} + -D ENABLE_LCD=1 +build_src_filter = +<*> +lib_deps = + ${core.lib_deps} + https://github.com/iakop/LiquidCrystal_I2C_ESP32 + +[env:esp32-s3-mini-lcd] +extends = env:esp32-s3-mini +build_flags = + ${env:esp32-s3-mini.build_flags} + -D ENABLE_LCD=1 +build_src_filter = +<*> +lib_deps = + ${core.lib_deps} + https://github.com/iakop/LiquidCrystal_I2C_ESP32 From 3aabefb73b7c7e8fc4563ecbe66367ddce48c370 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 12:57:06 +0200 Subject: [PATCH 039/125] Redesign miniweb dashboard with modern left navigation --- miniweb/src/main.ts | 191 +++++++++++++++++++---------- miniweb/src/style.css | 276 ++++++++++++++++++++++-------------------- 2 files changed, 268 insertions(+), 199 deletions(-) diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index d5482bd..f852ebc 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -2,8 +2,7 @@ import "./style.css"; import van from "vanjs-core"; import { roastApp } from "./roast"; import { logsApp } from "./logs"; -import { profile, ProfileControl } from "./profiling.ts"; -import { PIDController } from "./pid.ts"; +import { ProfileControl } from "./profiling.ts"; import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; import { getBasicAuthHeaderValue } from "./auth"; @@ -19,8 +18,12 @@ interface DeviceInfo { csrfToken?: string; } +type DashboardTab = "overview" | "control" | "network"; + const { button, div, input, p, span, h1, h2 } = van.tags; +const activeTab = van.state("overview"); + // State variables const pidPFactor = van.state(1.0); const pidIFactor = van.state(0.1); @@ -91,73 +94,92 @@ const updateWifiSettings = async () => { } }; +const navTab = (tab: DashboardTab, label: string, subtitle: string) => + button( + { + class: () => + activeTab.val === tab ? "sidebar-tab is-active" : "sidebar-tab", + onclick: () => { + activeTab.val = tab; + }, + }, + div({ class: "sidebar-tab-title" }, label), + div({ class: "sidebar-tab-subtitle" }, subtitle), + ); + // PID Configuration const PIDConfig = () => div( - "PID Factors", - p(), - "P:", - input({ - type: "number", - value: pidPFactor.val, - oninput: (e: Event) => { - pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - "I:", - input({ - type: "number", - value: pidIFactor.val, - oninput: (e: Event) => { - pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - "D:", - input({ - type: "number", - value: pidDFactor.val, - oninput: (e: Event) => { - pidDFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), + { class: "section" }, + h2("PID Settings"), + div( + { class: "settings-grid" }, + p("P Factor"), + input({ + type: "number", + value: pidPFactor.val, + oninput: (e: Event) => { + pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + p("I Factor"), + input({ + type: "number", + value: pidIFactor.val, + oninput: (e: Event) => { + pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + p("D Factor"), + input({ + type: "number", + value: pidDFactor.val, + oninput: (e: Event) => { + pidDFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), ); // Connection Status Display const ConnectionStatus = () => div( - { class: "connection-status" }, - "Connection Status: ", - span( - { - style: () => - `color: ${ - connectionStatus.val === "Connected" - ? "green" - : connectionStatus.val === "Error" - ? "red" - : "orange" - }`, - }, - () => connectionStatus.val, + { class: "section" }, + h2("Connection"), + p( + "Connection Status: ", + span( + { + style: () => + `color: ${ + connectionStatus.val === "Connected" + ? "#16a34a" + : connectionStatus.val === "Error" + ? "#dc2626" + : "#f59e0b" + }`, + }, + () => connectionStatus.val, + ), ), + p("Last telemetry update: ", () => lastUpdate.val?.toString() ?? "N/A"), ); // Sensor Data Display const SensorData = () => div( - { class: "sensor-data" }, - "Current Readings:", + { class: "section" }, + h2("Sensors"), p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), p("Sensor sample age: ", () => lastMessage.val?.sampleAgeMs ?? "N/A", " ms"), p("Sensor status: ", () => (lastMessage.val?.sensorOk ? "OK" : "BUSY/STALE")), - p("Last update: ", () => lastUpdate.val?.toString() ?? "N/A"), ); const VersionAndNetworkInfo = () => div( { class: "section" }, - h2("Version & Network Info"), + h2("Version & Network"), p("Web UI version: ", appVersion), p("Web UI build: ", buildTimestamp), p("Viewed via: ", location.origin), @@ -182,51 +204,46 @@ const VersionAndNetworkInfo = () => button({ onclick: refreshDeviceInfo }, "Refresh Info"), ); -// Start page UI -const startPage = div( +const WifiSettings = () => div( - { class: "start-page" }, - h1("Yaeger Roaster Control"), - ConnectionStatus, - SensorData, - VersionAndNetworkInfo, - div({ class: "section" }, h2("Profile Selection"), ProfileControl), - div({ class: "section" }, h2("PID Settings"), PIDConfig), + { class: "section" }, + h2("Wifi Settings"), div( - { class: "section" }, - h2("Wifi Settings"), - p(), - "Wifi ssid:", + { class: "settings-grid" }, + p("Wifi SSID"), input({ type: "text", oninput: (e: Event) => { ssidField.val = (e.target as HTMLInputElement).value; }, }), - p(), - "Wifi pass (if any)", + p("Wifi Password"), input({ type: "password", oninput: (e: Event) => { passField.val = (e.target as HTMLInputElement).value; }, }), - p(), - button({ onclick: updateWifiSettings }, "Update Wifi"), ), + button({ onclick: updateWifiSettings }, "Update Wifi"), + ); + +const QuickActions = () => + div( + { class: "section" }, + h2("Quick Actions"), + p("Start a roast session or inspect device logs."), div( - { class: "section" }, + { class: "action-row" }, button( { onclick: () => { - // Navigate to roast page document.getElementById("app")!.innerHTML = ""; van.add(document.getElementById("app")!, roastApp()); }, }, "Start Roasting", ), - " ", button( { onclick: () => { @@ -235,9 +252,49 @@ const startPage = div( window.history.pushState({}, "", "/logs"); }, }, - "Logs", + "Open Logs", ), ), + ); + +const DashboardPanel = () => + div( + { class: "dashboard-panel" }, + () => { + if (activeTab.val === "overview") { + return div(ConnectionStatus, SensorData, QuickActions); + } + if (activeTab.val === "control") { + return div( + div({ class: "section" }, h2("Profile Selection"), ProfileControl), + PIDConfig, + QuickActions, + ); + } + + return div(VersionAndNetworkInfo, WifiSettings); + }, + ); + +// Start page UI +const startPage = div( + { class: "app-shell" }, + div( + { class: "sidebar" }, + h1("Yaeger"), + p({ class: "sidebar-subtitle" }, "Roaster Control"), + navTab("overview", "Overview", "Live status and sensors"), + navTab("control", "Roast Controls", "Profiles and PID tuning"), + navTab("network", "Network", "Device info and wifi"), + ), + div( + { class: "content" }, + div( + { class: "content-header" }, + h2("Control Center"), + p("Modernized dashboard with task-focused tabs."), + ), + DashboardPanel, ), ); diff --git a/miniweb/src/style.css b/miniweb/src/style.css index 02d14a7..518319f 100644 --- a/miniweb/src/style.css +++ b/miniweb/src/style.css @@ -1,230 +1,242 @@ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /* === ShadCN design tokens === */ - --radius: 0.5rem; - - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; - - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; + --radius: 0.75rem; + --background: 220 23% 97%; + --foreground: 225 23% 12%; + --surface: 0 0% 100%; + --border: 222 22% 90%; + --input: 222 22% 90%; + --ring: 222 84% 58%; + --primary: 222 84% 55%; + --primary-foreground: 0 0% 100%; + --secondary: 220 24% 96%; + --secondary-foreground: 225 20% 20%; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; +* { + box-sizing: border-box; } body { margin: 0; - /*display: flex;*/ - place-items: center; min-width: 320px; min-height: 100vh; background: hsl(var(--background)); color: hsl(var(--foreground)); } -control_cluster { - place-content: align; - background-color: #ff0000; +h1, +h2, +p { + margin: 0; +} + +#app { + min-height: 100vh; } -h1 { - font-size: 3.2em; - line-height: 1.1; +.app-shell { + display: grid; + grid-template-columns: 280px 1fr; + min-height: 100vh; } -#app { - max-width: 1280px; - margin: 0 auto; - /*padding: 5rem;*/ - text-align: center; +.sidebar { + border-right: 1px solid hsl(var(--border)); + background: linear-gradient(180deg, #0f172a 0%, #111827 100%); + color: #e5e7eb; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sidebar h1 { + font-size: 1.5rem; +} + +.sidebar-subtitle { + color: #9ca3af; + margin-bottom: 0.75rem; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +.sidebar-tab { + all: unset; + cursor: pointer; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 0.8rem 0.9rem; + background: rgba(255, 255, 255, 0.04); +} + +.sidebar-tab:hover { + background: rgba(255, 255, 255, 0.08); +} + +.sidebar-tab.is-active { + border-color: rgba(148, 163, 184, 0.45); + background: rgba(59, 130, 246, 0.22); +} + +.sidebar-tab-title { + font-size: 0.95rem; + font-weight: 600; +} + +.sidebar-tab-subtitle { + font-size: 0.8rem; + color: #cbd5e1; +} + +.content { + padding: 2rem; } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + +.content-header { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 1rem; } -.logo.vanilla:hover { - filter: drop-shadow(0 0 2em #3178c6aa); + +.content-header h2 { + font-size: 1.8rem; } -.card { - padding: 2em; +.content-header p { + color: #64748b; } -.read-the-docs { - color: #888; +.dashboard-panel { + max-width: 900px; +} + +.section { + padding: 1.25rem; + margin-top: 1rem; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--surface)); + color: hsl(var(--foreground)); + display: flex; + flex-direction: column; + gap: 0.7rem; +} + +.settings-grid { + display: grid; + grid-template-columns: 140px 1fr; + align-items: center; + gap: 0.75rem; +} + +.action-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; } -/* ------------------- ShadCN-style Button ------------------- */ button { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; - height: 2.5rem; padding: 0 1rem; font-size: 0.875rem; - font-weight: 500; + font-weight: 600; font-family: inherit; - border-radius: var(--radius); border: 1px solid hsl(var(--border)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); cursor: pointer; - - transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; } + button:hover { - background: hsl(var(--primary) / 0.9); + filter: brightness(0.95); } + button:disabled { opacity: 0.5; pointer-events: none; } -button:focus-visible { - outline: 2px solid transparent; - outline-offset: 2px; - box-shadow: 0 0 0 2px hsl(var(--ring)); -} -/* ------------------- ShadCN-style Inputs ------------------- */ -input:not([type="range"]) { - height: 2.5rem; + +input:not([type="range"]), +textarea, +select { width: 100%; - padding: 0 0.75rem; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; font-size: 0.875rem; - border-radius: var(--radius); border: 1px solid hsl(var(--input)); - background: hsl(var(--background)); + background: hsl(var(--surface)); color: hsl(var(--foreground)); - - transition: border-color 0.15s ease, box-shadow 0.15s ease; } -input:not([type="range"]):focus { + +input:not([type="range"]):focus, +textarea:focus, +select:focus { outline: none; border-color: hsl(var(--ring)); - box-shadow: 0 0 0 2px hsl(var(--ring) / 0.4); -} -/* ------------------- Card & Section ------------------- */ -.card, -.section { - padding: 1.5rem; - margin-top: 1.5rem; - border: 1px solid hsl(var(--border)); - border-radius: var(--radius); - background: hsl(var(--secondary)); - color: hsl(var(--secondary-foreground)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.25); } -.section h2 { - margin-top: 0; - margin-bottom: 1rem; - font-size: 1.125rem; - font-weight: 600; -} -/* ------------------- Dark-mode token overrides ------------------- */ -@media (prefers-color-scheme: dark) { - :root { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83%; - - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; - } +.start-page { + max-width: 1080px; + margin: 0 auto; + padding: 2rem; } -/* Style the slider */ input[type="range"] { -webkit-appearance: none; width: 220px; height: 6px; - /*background: #ddd;*/ border-radius: 3px; position: relative; margin: 20px 0; } -/* Remove default outline on focus */ input[type="range"]:focus { outline: none; } -/* Track styles */ input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: #ddd; border-radius: 3px; } -/* Thumb styles */ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; height: 20px; width: 20px; border-radius: 50%; - background: #007BFF; + background: #007bff; border: 2px solid #fff; cursor: pointer; - margin-top: -7px; /* Center thumb */ + margin-top: -7px; } -/* Disabled slider styles */ -input[type="range"]:disabled { - opacity: 0.5; - cursor: not-allowed; -} +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } -input[type="range"]:disabled::-webkit-slider-runnable-track { - background: #bbb; /* Lighter track for disabled state */ -} + .sidebar { + border-right: 0; + border-bottom: 1px solid hsl(var(--border)); + } -input[type="range"]:disabled::-webkit-slider-thumb { - background: #888; /* Gray thumb for disabled state */ - border: 2px solid #ccc; - cursor: not-allowed; + .settings-grid { + grid-template-columns: 1fr; + } } From 5525b69c0febd7552e5f6310783800252f90c482 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 14:18:10 +0200 Subject: [PATCH 040/125] Migrate miniweb shell to React and trim copy --- miniweb/index.html | 2 +- miniweb/package.json | 7 +- miniweb/src/main.ts | 302 --------------------------------- miniweb/src/main.tsx | 196 +++++++++++++++++++++ miniweb/tsconfig.json | 7 +- miniweb/vite.config.ts | 3 +- miniweb/yarn.lock | 376 +++++++++++++++++++++++++---------------- 7 files changed, 438 insertions(+), 455 deletions(-) delete mode 100644 miniweb/src/main.ts create mode 100644 miniweb/src/main.tsx diff --git a/miniweb/index.html b/miniweb/index.html index 461c6ba..01e2808 100644 --- a/miniweb/index.html +++ b/miniweb/index.html @@ -7,6 +7,6 @@
- + diff --git a/miniweb/package.json b/miniweb/package.json index 58b28fa..a001e2b 100644 --- a/miniweb/package.json +++ b/miniweb/package.json @@ -11,7 +11,8 @@ "devDependencies": { "@types/chartjs-plugin-trendline": "^1.0.4", "typescript": "~5.6.2", - "vite": "^6.0.3" + "vite": "^6.0.3", + "@vitejs/plugin-react": "^4.4.1" }, "dependencies": { "chart.js": "^4.4.7", @@ -19,6 +20,8 @@ "date-fns": "^4.1.0", "vanjs-core": "^1.5.2", "vanjs-ext": "^0.6.2", - "vite-plugin-pwa": "^0.21.1" + "vite-plugin-pwa": "^0.21.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" } } diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts deleted file mode 100644 index f852ebc..0000000 --- a/miniweb/src/main.ts +++ /dev/null @@ -1,302 +0,0 @@ -import "./style.css"; -import van from "vanjs-core"; -import { roastApp } from "./roast"; -import { logsApp } from "./logs"; -import { ProfileControl } from "./profiling.ts"; -import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; -import { getBasicAuthHeaderValue } from "./auth"; - -declare const __APP_VERSION__: string; -declare const __BUILD_TIMESTAMP__: string; - -interface DeviceInfo { - firmwareVersion: string; - networkMode: string; - ssid: string; - ip: string; - hostname: string; - csrfToken?: string; -} - -type DashboardTab = "overview" | "control" | "network"; - -const { button, div, input, p, span, h1, h2 } = van.tags; - -const activeTab = van.state("overview"); - -// State variables -const pidPFactor = van.state(1.0); -const pidIFactor = van.state(0.1); -const pidDFactor = van.state(0.01); - -// Wifi -const ssidField = van.state(""); -const passField = van.state(""); - -// Versioning and network details -const deviceInfo = van.state(null); -const deviceInfoError = van.state(null); -const csrfToken = van.state(""); - -const appVersion = __APP_VERSION__; -const buildTimestamp = new Date(__BUILD_TIMESTAMP__).toLocaleString(); - -const refreshDeviceInfo = async () => { - try { - deviceInfoError.val = null; - const response = await fetch(`http://${location.host}/api/info`); - if (!response.ok) { - throw new Error(`API returned ${response.status}`); - } - - deviceInfo.val = (await response.json()) as DeviceInfo; - csrfToken.val = deviceInfo.val.csrfToken || ""; - } catch (error: unknown) { - deviceInfo.val = null; - if (error instanceof Error) { - deviceInfoError.val = error.message; - } else { - deviceInfoError.val = "Unknown error"; - } - } -}; - -void refreshDeviceInfo(); - -const updateWifiSettings = async () => { - const ssid = ssidField.val; - const pass = passField.val; - - try { - const response = await fetch(`http://${location.host}/api/wifi`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: getBasicAuthHeaderValue(), - "X-Yaeger-CSRF": csrfToken.val, - }, - body: JSON.stringify({ ssid, pass }), - }); - if (response.ok) { - alert( - "Wifi settings updated!\nPlease restart for the new settings to take effect", - ); - await refreshDeviceInfo(); - } else { - alert(`Something happened: ${response.status}`); - } - } catch (error: unknown) { - if (error instanceof Error) { - alert(`Error: ${error.message}`); - } else { - alert("An unknown error occurred"); - } - } -}; - -const navTab = (tab: DashboardTab, label: string, subtitle: string) => - button( - { - class: () => - activeTab.val === tab ? "sidebar-tab is-active" : "sidebar-tab", - onclick: () => { - activeTab.val = tab; - }, - }, - div({ class: "sidebar-tab-title" }, label), - div({ class: "sidebar-tab-subtitle" }, subtitle), - ); - -// PID Configuration -const PIDConfig = () => - div( - { class: "section" }, - h2("PID Settings"), - div( - { class: "settings-grid" }, - p("P Factor"), - input({ - type: "number", - value: pidPFactor.val, - oninput: (e: Event) => { - pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - p("I Factor"), - input({ - type: "number", - value: pidIFactor.val, - oninput: (e: Event) => { - pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - p("D Factor"), - input({ - type: "number", - value: pidDFactor.val, - oninput: (e: Event) => { - pidDFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - ); - -// Connection Status Display -const ConnectionStatus = () => - div( - { class: "section" }, - h2("Connection"), - p( - "Connection Status: ", - span( - { - style: () => - `color: ${ - connectionStatus.val === "Connected" - ? "#16a34a" - : connectionStatus.val === "Error" - ? "#dc2626" - : "#f59e0b" - }`, - }, - () => connectionStatus.val, - ), - ), - p("Last telemetry update: ", () => lastUpdate.val?.toString() ?? "N/A"), - ); - -// Sensor Data Display -const SensorData = () => - div( - { class: "section" }, - h2("Sensors"), - p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), - p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), - p("Sensor sample age: ", () => lastMessage.val?.sampleAgeMs ?? "N/A", " ms"), - p("Sensor status: ", () => (lastMessage.val?.sensorOk ? "OK" : "BUSY/STALE")), - ); - -const VersionAndNetworkInfo = () => - div( - { class: "section" }, - h2("Version & Network"), - p("Web UI version: ", appVersion), - p("Web UI build: ", buildTimestamp), - p("Viewed via: ", location.origin), - () => - deviceInfo.val - ? div( - p("Firmware version: ", deviceInfo.val.firmwareVersion), - p("Network mode: ", deviceInfo.val.networkMode), - p("SSID: ", deviceInfo.val.ssid || "N/A"), - p("IP address: ", deviceInfo.val.ip || "N/A"), - p("Hostname: ", deviceInfo.val.hostname || "N/A"), - ) - : p("Device info unavailable"), - () => - deviceInfoError.val - ? p( - { style: "color: #b91c1c;" }, - "Could not load network info: ", - deviceInfoError.val, - ) - : null, - button({ onclick: refreshDeviceInfo }, "Refresh Info"), - ); - -const WifiSettings = () => - div( - { class: "section" }, - h2("Wifi Settings"), - div( - { class: "settings-grid" }, - p("Wifi SSID"), - input({ - type: "text", - oninput: (e: Event) => { - ssidField.val = (e.target as HTMLInputElement).value; - }, - }), - p("Wifi Password"), - input({ - type: "password", - oninput: (e: Event) => { - passField.val = (e.target as HTMLInputElement).value; - }, - }), - ), - button({ onclick: updateWifiSettings }, "Update Wifi"), - ); - -const QuickActions = () => - div( - { class: "section" }, - h2("Quick Actions"), - p("Start a roast session or inspect device logs."), - div( - { class: "action-row" }, - button( - { - onclick: () => { - document.getElementById("app")!.innerHTML = ""; - van.add(document.getElementById("app")!, roastApp()); - }, - }, - "Start Roasting", - ), - button( - { - onclick: () => { - document.getElementById("app")!.innerHTML = ""; - van.add(document.getElementById("app")!, logsApp()); - window.history.pushState({}, "", "/logs"); - }, - }, - "Open Logs", - ), - ), - ); - -const DashboardPanel = () => - div( - { class: "dashboard-panel" }, - () => { - if (activeTab.val === "overview") { - return div(ConnectionStatus, SensorData, QuickActions); - } - if (activeTab.val === "control") { - return div( - div({ class: "section" }, h2("Profile Selection"), ProfileControl), - PIDConfig, - QuickActions, - ); - } - - return div(VersionAndNetworkInfo, WifiSettings); - }, - ); - -// Start page UI -const startPage = div( - { class: "app-shell" }, - div( - { class: "sidebar" }, - h1("Yaeger"), - p({ class: "sidebar-subtitle" }, "Roaster Control"), - navTab("overview", "Overview", "Live status and sensors"), - navTab("control", "Roast Controls", "Profiles and PID tuning"), - navTab("network", "Network", "Device info and wifi"), - ), - div( - { class: "content" }, - div( - { class: "content-header" }, - h2("Control Center"), - p("Modernized dashboard with task-focused tabs."), - ), - DashboardPanel, - ), -); - -// Attach UI to DOM -van.add(document.getElementById("app")!, startPage); diff --git a/miniweb/src/main.tsx b/miniweb/src/main.tsx new file mode 100644 index 0000000..61010d1 --- /dev/null +++ b/miniweb/src/main.tsx @@ -0,0 +1,196 @@ +import "./style.css"; +import { StrictMode, useEffect, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import van from "vanjs-core"; +import { roastApp } from "./roast"; +import { logsApp } from "./logs"; +import { ProfileControl } from "./profiling"; +import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; +import { getBasicAuthHeaderValue } from "./auth"; + +declare const __APP_VERSION__: string; +declare const __BUILD_TIMESTAMP__: string; + +interface DeviceInfo { + firmwareVersion: string; + networkMode: string; + ssid: string; + ip: string; + hostname: string; + csrfToken?: string; +} + +type DashboardTab = "overview" | "control" | "network"; + +function App() { + const [activeTab, setActiveTab] = useState("overview"); + const [pidPFactor, setPidPFactor] = useState(1.0); + const [pidIFactor, setPidIFactor] = useState(0.1); + const [pidDFactor, setPidDFactor] = useState(0.01); + const [ssid, setSsid] = useState(""); + const [pass, setPass] = useState(""); + const [deviceInfo, setDeviceInfo] = useState(null); + const [deviceInfoError, setDeviceInfoError] = useState(null); + const [csrfToken, setCsrfToken] = useState(""); + const [tick, setTick] = useState(0); + + useEffect(() => { + const id = window.setInterval(() => setTick((x) => x + 1), 400); + return () => window.clearInterval(id); + }, []); + + const refreshDeviceInfo = async () => { + try { + setDeviceInfoError(null); + const response = await fetch(`http://${location.host}/api/info`); + if (!response.ok) { + throw new Error(`API returned ${response.status}`); + } + + const info = (await response.json()) as DeviceInfo; + setDeviceInfo(info); + setCsrfToken(info.csrfToken || ""); + } catch (error: unknown) { + setDeviceInfo(null); + setDeviceInfoError(error instanceof Error ? error.message : "Unknown error"); + } + }; + + useEffect(() => { + void refreshDeviceInfo(); + }, []); + + const updateWifiSettings = async () => { + try { + const response = await fetch(`http://${location.host}/api/wifi`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": csrfToken, + }, + body: JSON.stringify({ ssid, pass }), + }); + if (response.ok) { + alert("Wifi updated. Restart to apply."); + await refreshDeviceInfo(); + } else { + alert(`Request failed: ${response.status}`); + } + } catch (error: unknown) { + alert(error instanceof Error ? error.message : "Unknown error"); + } + }; + + const sensorSnapshot = useMemo( + () => ({ + connection: connectionStatus.val, + message: lastMessage.val, + updated: lastUpdate.val, + }), + [tick], + ); + + const openRoast = () => { + const app = document.getElementById("app"); + if (!app) return; + app.innerHTML = ""; + van.add(app, roastApp()); + }; + + const openLogs = () => { + const app = document.getElementById("app"); + if (!app) return; + app.innerHTML = ""; + van.add(app, logsApp()); + window.history.pushState({}, "", "/logs"); + }; + + return ( +
+ + +
+ {activeTab === "overview" && ( + <> +
+

Connection

+

Status: {sensorSnapshot.connection}

+

Last update: {sensorSnapshot.updated?.toString() ?? "N/A"}

+
+
+

Sensors

+

ET: {sensorSnapshot.message?.ET ?? "N/A"}°C

+

BT: {sensorSnapshot.message?.BT ?? "N/A"}°C

+

Age: {sensorSnapshot.message?.sampleAgeMs ?? "N/A"} ms

+
+ + )} + + {activeTab === "control" && ( + <> +
+

Profile

+ +
+
+

PID

+
+

P

+ setPidPFactor(Number.parseFloat(e.target.value) || 0)} /> +

I

+ setPidIFactor(Number.parseFloat(e.target.value) || 0)} /> +

D

+ setPidDFactor(Number.parseFloat(e.target.value) || 0)} /> +
+
+ + )} + + {activeTab === "network" && ( + <> +
+

Versions

+

Web UI: {__APP_VERSION__}

+

Build: {new Date(__BUILD_TIMESTAMP__).toLocaleString()}

+

Firmware: {deviceInfo?.firmwareVersion ?? "N/A"}

+

Mode: {deviceInfo?.networkMode ?? "N/A"}

+

SSID: {deviceInfo?.ssid || "N/A"}

+

IP: {deviceInfo?.ip || "N/A"}

+ {deviceInfoError &&

{deviceInfoError}

} + +
+
+

Wifi

+
+

SSID

+ setSsid(e.target.value)} /> +

Password

+ setPass(e.target.value)} /> +
+ +
+ + )} + +
+
+ + +
+
+
+
+ ); +} + +createRoot(document.getElementById("app") as HTMLElement).render( + + + , +); diff --git a/miniweb/tsconfig.json b/miniweb/tsconfig.json index a4883f2..fac25fc 100644 --- a/miniweb/tsconfig.json +++ b/miniweb/tsconfig.json @@ -5,20 +5,17 @@ "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, - - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, - - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "jsx": "react-jsx" }, "include": ["src"] } diff --git a/miniweb/vite.config.ts b/miniweb/vite.config.ts index 8e0c7c8..3ebabf4 100644 --- a/miniweb/vite.config.ts +++ b/miniweb/vite.config.ts @@ -1,3 +1,4 @@ +import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { VitePWA } from "vite-plugin-pwa"; import packageJson from "./package.json"; @@ -48,7 +49,7 @@ export default defineConfig(async () => ({ __BUILD_TIMESTAMP__: JSON.stringify(new Date().toISOString()), }, plugins: [ - // react(), + react(), // viteTsconfigPaths(), // svgrPlugin(), // viteCompression(), diff --git a/miniweb/yarn.lock b/miniweb/yarn.lock index f40c61a..8be2048 100644 --- a/miniweb/yarn.lock +++ b/miniweb/yarn.lock @@ -2,14 +2,6 @@ # yarn lockfile v1 -"@ampproject/remapping@^2.2.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz" - integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.24" - "@apideck/better-ajv-errors@^0.3.1": version "0.3.6" resolved "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz" @@ -19,50 +11,50 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.0", "@babel/code-frame@^7.26.2": - version "7.26.2" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz" - integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== +"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== dependencies: - "@babel/helper-validator-identifier" "^7.25.9" + "@babel/helper-validator-identifier" "^7.28.5" js-tokens "^4.0.0" - picocolors "^1.0.0" - -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.9", "@babel/compat-data@^7.26.0": - version "7.26.3" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz" - integrity sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g== + picocolors "^1.1.1" -"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.0.0-0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.24.4", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0": - version "7.26.0" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz" - integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg== - dependencies: - "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.26.0" - "@babel/generator" "^7.26.0" - "@babel/helper-compilation-targets" "^7.25.9" - "@babel/helper-module-transforms" "^7.26.0" - "@babel/helpers" "^7.26.0" - "@babel/parser" "^7.26.0" - "@babel/template" "^7.25.9" - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.26.0" +"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.0", "@babel/compat-data@^7.28.6": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz" + integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + +"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.0.0-0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.24.4", "@babel/core@^7.28.0", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz" + integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/remapping" "^2.3.5" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.26.0", "@babel/generator@^7.26.3": - version "7.26.3" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz" - integrity sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ== +"@babel/generator@^7.29.0": + version "7.29.1" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== dependencies: - "@babel/parser" "^7.26.3" - "@babel/types" "^7.26.3" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" "@babel/helper-annotate-as-pure@^7.25.9": @@ -72,13 +64,13 @@ dependencies: "@babel/types" "^7.25.9" -"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz" - integrity sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ== +"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9", "@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== dependencies: - "@babel/compat-data" "^7.25.9" - "@babel/helper-validator-option" "^7.25.9" + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" browserslist "^4.24.0" lru-cache "^5.1.1" semver "^6.3.1" @@ -116,6 +108,11 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + "@babel/helper-member-expression-to-functions@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz" @@ -124,22 +121,22 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz" - integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0": - version "7.26.0" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz" - integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== +"@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0", "@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" "@babel/helper-optimise-call-expression@^7.25.9": version "7.25.9" @@ -148,10 +145,10 @@ dependencies: "@babel/types" "^7.25.9" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz" - integrity sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.27.1": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== "@babel/helper-remap-async-to-generator@^7.25.9": version "7.25.9" @@ -179,20 +176,20 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-string-parser@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz" - integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== -"@babel/helper-validator-identifier@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" - integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.25.9", "@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== -"@babel/helper-validator-option@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz" - integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== +"@babel/helper-validator-option@^7.25.9", "@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== "@babel/helper-wrap-function@^7.25.9": version "7.25.9" @@ -203,20 +200,20 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helpers@^7.26.0": - version "7.26.0" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz" - integrity sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw== +"@babel/helpers@^7.28.6": + version "7.29.2" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz" + integrity sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw== dependencies: - "@babel/template" "^7.25.9" - "@babel/types" "^7.26.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" -"@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.3": - version "7.26.3" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz" - integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA== +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.2" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz" + integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== dependencies: - "@babel/types" "^7.26.3" + "@babel/types" "^7.29.0" "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9": version "7.25.9" @@ -581,6 +578,20 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" +"@babel/plugin-transform-react-jsx-self@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz" + integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-react-jsx-source@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz" + integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-regenerator@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz" @@ -762,48 +773,53 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.25.9": - version "7.25.9" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz" - integrity sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg== - dependencies: - "@babel/code-frame" "^7.25.9" - "@babel/parser" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/traverse@^7.25.9": - version "7.26.4" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz" - integrity sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w== - dependencies: - "@babel/code-frame" "^7.26.2" - "@babel/generator" "^7.26.3" - "@babel/parser" "^7.26.3" - "@babel/template" "^7.25.9" - "@babel/types" "^7.26.3" +"@babel/template@^7.25.9", "@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse@^7.25.9", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" debug "^4.3.1" - globals "^11.1.0" -"@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3", "@babel/types@^7.4.4": - version "7.26.3" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz" - integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA== +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.9", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.4.4": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== dependencies: - "@babel/helper-string-parser" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" -"@esbuild/darwin-arm64@0.24.0": +"@esbuild/linux-x64@0.24.0": version "0.24.0" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz" - integrity sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw== -"@jridgewell/gen-mapping@^0.3.5": - version "0.3.8" - resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz" - integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/resolve-uri@^3.1.0": @@ -811,11 +827,6 @@ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - "@jridgewell/source-map@^0.3.3": version "0.3.6" resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz" @@ -824,15 +835,15 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": version "1.5.0" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": - version "0.3.25" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -842,6 +853,11 @@ resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz" integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== + "@rollup/plugin-babel@^5.2.0": version "5.3.1" resolved "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz" @@ -896,10 +912,8 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-darwin-arm64@4.28.1": +"@rollup/rollup-linux-x64-gnu@4.28.1": version "4.28.1" - resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz" - integrity sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ== "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" @@ -911,6 +925,39 @@ magic-string "^0.25.0" string.prototype.matchall "^4.0.6" +"@types/babel__core@^7.1.9", "@types/babel__core@^7.20.5": + version "7.20.5" + resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*": + version "7.28.0" + resolved "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + "@types/chartjs-plugin-trendline@^1.0.4": version "1.0.4" resolved "https://registry.npmjs.org/@types/chartjs-plugin-trendline/-/chartjs-plugin-trendline-1.0.4.tgz" @@ -938,6 +985,18 @@ resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@vitejs/plugin-react@^4.4.1": + version "4.7.0" + resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== + dependencies: + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" + "@types/babel__core" "^7.20.5" + react-refresh "^0.17.0" + acorn@^8.8.2: version "8.14.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz" @@ -1428,11 +1487,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2, fsevents@~2.3.3: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -1788,7 +1842,7 @@ jake@^10.8.5: filelist "^1.0.4" minimatch "^3.1.2" -js-tokens@^4.0.0: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -1852,6 +1906,13 @@ lodash@^4.17.20: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" @@ -1939,7 +2000,7 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: +picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -1990,6 +2051,26 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-refresh@^0.17.0: + version "0.17.0" + resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz" + integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.9" resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.9.tgz" @@ -2136,6 +2217,13 @@ safe-regex-test@^1.1.0: es-errors "^1.3.0" is-regex "^1.2.1" +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" @@ -2498,7 +2586,7 @@ vite-plugin-pwa@^0.21.1: workbox-build "^7.3.0" workbox-window "^7.3.0" -"vite@^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", vite@^6.0.3: +"vite@^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", vite@^6.0.3: version "6.0.4" resolved "https://registry.npmjs.org/vite/-/vite-6.0.4.tgz" integrity sha512-zwlH6ar+6o6b4Wp+ydhtIKLrGM/LoqZzcdVmkGAFun0KHTzIzjh+h0kungEx7KJg/PYnC80I4TII9WkjciSR6Q== From 3ab4228a32a5c2a24b990f9ac33fe7b9ff5610a7 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 14:36:05 +0200 Subject: [PATCH 041/125] Revert "Redesign miniweb dashboard with modern left navigation" --- miniweb/index.html | 2 +- miniweb/package.json | 7 +- miniweb/src/main.ts | 245 +++++++++++++++++++++++++++ miniweb/src/main.tsx | 196 --------------------- miniweb/src/style.css | 276 +++++++++++++++--------------- miniweb/tsconfig.json | 7 +- miniweb/vite.config.ts | 3 +- miniweb/yarn.lock | 376 ++++++++++++++++------------------------- 8 files changed, 530 insertions(+), 582 deletions(-) create mode 100644 miniweb/src/main.ts delete mode 100644 miniweb/src/main.tsx diff --git a/miniweb/index.html b/miniweb/index.html index 01e2808..461c6ba 100644 --- a/miniweb/index.html +++ b/miniweb/index.html @@ -7,6 +7,6 @@
- + diff --git a/miniweb/package.json b/miniweb/package.json index a001e2b..58b28fa 100644 --- a/miniweb/package.json +++ b/miniweb/package.json @@ -11,8 +11,7 @@ "devDependencies": { "@types/chartjs-plugin-trendline": "^1.0.4", "typescript": "~5.6.2", - "vite": "^6.0.3", - "@vitejs/plugin-react": "^4.4.1" + "vite": "^6.0.3" }, "dependencies": { "chart.js": "^4.4.7", @@ -20,8 +19,6 @@ "date-fns": "^4.1.0", "vanjs-core": "^1.5.2", "vanjs-ext": "^0.6.2", - "vite-plugin-pwa": "^0.21.1", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "vite-plugin-pwa": "^0.21.1" } } diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts new file mode 100644 index 0000000..d5482bd --- /dev/null +++ b/miniweb/src/main.ts @@ -0,0 +1,245 @@ +import "./style.css"; +import van from "vanjs-core"; +import { roastApp } from "./roast"; +import { logsApp } from "./logs"; +import { profile, ProfileControl } from "./profiling.ts"; +import { PIDController } from "./pid.ts"; +import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; +import { getBasicAuthHeaderValue } from "./auth"; + +declare const __APP_VERSION__: string; +declare const __BUILD_TIMESTAMP__: string; + +interface DeviceInfo { + firmwareVersion: string; + networkMode: string; + ssid: string; + ip: string; + hostname: string; + csrfToken?: string; +} + +const { button, div, input, p, span, h1, h2 } = van.tags; + +// State variables +const pidPFactor = van.state(1.0); +const pidIFactor = van.state(0.1); +const pidDFactor = van.state(0.01); + +// Wifi +const ssidField = van.state(""); +const passField = van.state(""); + +// Versioning and network details +const deviceInfo = van.state(null); +const deviceInfoError = van.state(null); +const csrfToken = van.state(""); + +const appVersion = __APP_VERSION__; +const buildTimestamp = new Date(__BUILD_TIMESTAMP__).toLocaleString(); + +const refreshDeviceInfo = async () => { + try { + deviceInfoError.val = null; + const response = await fetch(`http://${location.host}/api/info`); + if (!response.ok) { + throw new Error(`API returned ${response.status}`); + } + + deviceInfo.val = (await response.json()) as DeviceInfo; + csrfToken.val = deviceInfo.val.csrfToken || ""; + } catch (error: unknown) { + deviceInfo.val = null; + if (error instanceof Error) { + deviceInfoError.val = error.message; + } else { + deviceInfoError.val = "Unknown error"; + } + } +}; + +void refreshDeviceInfo(); + +const updateWifiSettings = async () => { + const ssid = ssidField.val; + const pass = passField.val; + + try { + const response = await fetch(`http://${location.host}/api/wifi`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": csrfToken.val, + }, + body: JSON.stringify({ ssid, pass }), + }); + if (response.ok) { + alert( + "Wifi settings updated!\nPlease restart for the new settings to take effect", + ); + await refreshDeviceInfo(); + } else { + alert(`Something happened: ${response.status}`); + } + } catch (error: unknown) { + if (error instanceof Error) { + alert(`Error: ${error.message}`); + } else { + alert("An unknown error occurred"); + } + } +}; + +// PID Configuration +const PIDConfig = () => + div( + "PID Factors", + p(), + "P:", + input({ + type: "number", + value: pidPFactor.val, + oninput: (e: Event) => { + pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + "I:", + input({ + type: "number", + value: pidIFactor.val, + oninput: (e: Event) => { + pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + "D:", + input({ + type: "number", + value: pidDFactor.val, + oninput: (e: Event) => { + pidDFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ); + +// Connection Status Display +const ConnectionStatus = () => + div( + { class: "connection-status" }, + "Connection Status: ", + span( + { + style: () => + `color: ${ + connectionStatus.val === "Connected" + ? "green" + : connectionStatus.val === "Error" + ? "red" + : "orange" + }`, + }, + () => connectionStatus.val, + ), + ); + +// Sensor Data Display +const SensorData = () => + div( + { class: "sensor-data" }, + "Current Readings:", + p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), + p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), + p("Sensor sample age: ", () => lastMessage.val?.sampleAgeMs ?? "N/A", " ms"), + p("Sensor status: ", () => (lastMessage.val?.sensorOk ? "OK" : "BUSY/STALE")), + p("Last update: ", () => lastUpdate.val?.toString() ?? "N/A"), + ); + +const VersionAndNetworkInfo = () => + div( + { class: "section" }, + h2("Version & Network Info"), + p("Web UI version: ", appVersion), + p("Web UI build: ", buildTimestamp), + p("Viewed via: ", location.origin), + () => + deviceInfo.val + ? div( + p("Firmware version: ", deviceInfo.val.firmwareVersion), + p("Network mode: ", deviceInfo.val.networkMode), + p("SSID: ", deviceInfo.val.ssid || "N/A"), + p("IP address: ", deviceInfo.val.ip || "N/A"), + p("Hostname: ", deviceInfo.val.hostname || "N/A"), + ) + : p("Device info unavailable"), + () => + deviceInfoError.val + ? p( + { style: "color: #b91c1c;" }, + "Could not load network info: ", + deviceInfoError.val, + ) + : null, + button({ onclick: refreshDeviceInfo }, "Refresh Info"), + ); + +// Start page UI +const startPage = div( + div( + { class: "start-page" }, + h1("Yaeger Roaster Control"), + ConnectionStatus, + SensorData, + VersionAndNetworkInfo, + div({ class: "section" }, h2("Profile Selection"), ProfileControl), + div({ class: "section" }, h2("PID Settings"), PIDConfig), + div( + { class: "section" }, + h2("Wifi Settings"), + p(), + "Wifi ssid:", + input({ + type: "text", + oninput: (e: Event) => { + ssidField.val = (e.target as HTMLInputElement).value; + }, + }), + p(), + "Wifi pass (if any)", + input({ + type: "password", + oninput: (e: Event) => { + passField.val = (e.target as HTMLInputElement).value; + }, + }), + p(), + button({ onclick: updateWifiSettings }, "Update Wifi"), + ), + div( + { class: "section" }, + button( + { + onclick: () => { + // Navigate to roast page + document.getElementById("app")!.innerHTML = ""; + van.add(document.getElementById("app")!, roastApp()); + }, + }, + "Start Roasting", + ), + " ", + button( + { + onclick: () => { + document.getElementById("app")!.innerHTML = ""; + van.add(document.getElementById("app")!, logsApp()); + window.history.pushState({}, "", "/logs"); + }, + }, + "Logs", + ), + ), + ), +); + +// Attach UI to DOM +van.add(document.getElementById("app")!, startPage); diff --git a/miniweb/src/main.tsx b/miniweb/src/main.tsx deleted file mode 100644 index 61010d1..0000000 --- a/miniweb/src/main.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import "./style.css"; -import { StrictMode, useEffect, useMemo, useState } from "react"; -import { createRoot } from "react-dom/client"; -import van from "vanjs-core"; -import { roastApp } from "./roast"; -import { logsApp } from "./logs"; -import { ProfileControl } from "./profiling"; -import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; -import { getBasicAuthHeaderValue } from "./auth"; - -declare const __APP_VERSION__: string; -declare const __BUILD_TIMESTAMP__: string; - -interface DeviceInfo { - firmwareVersion: string; - networkMode: string; - ssid: string; - ip: string; - hostname: string; - csrfToken?: string; -} - -type DashboardTab = "overview" | "control" | "network"; - -function App() { - const [activeTab, setActiveTab] = useState("overview"); - const [pidPFactor, setPidPFactor] = useState(1.0); - const [pidIFactor, setPidIFactor] = useState(0.1); - const [pidDFactor, setPidDFactor] = useState(0.01); - const [ssid, setSsid] = useState(""); - const [pass, setPass] = useState(""); - const [deviceInfo, setDeviceInfo] = useState(null); - const [deviceInfoError, setDeviceInfoError] = useState(null); - const [csrfToken, setCsrfToken] = useState(""); - const [tick, setTick] = useState(0); - - useEffect(() => { - const id = window.setInterval(() => setTick((x) => x + 1), 400); - return () => window.clearInterval(id); - }, []); - - const refreshDeviceInfo = async () => { - try { - setDeviceInfoError(null); - const response = await fetch(`http://${location.host}/api/info`); - if (!response.ok) { - throw new Error(`API returned ${response.status}`); - } - - const info = (await response.json()) as DeviceInfo; - setDeviceInfo(info); - setCsrfToken(info.csrfToken || ""); - } catch (error: unknown) { - setDeviceInfo(null); - setDeviceInfoError(error instanceof Error ? error.message : "Unknown error"); - } - }; - - useEffect(() => { - void refreshDeviceInfo(); - }, []); - - const updateWifiSettings = async () => { - try { - const response = await fetch(`http://${location.host}/api/wifi`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: getBasicAuthHeaderValue(), - "X-Yaeger-CSRF": csrfToken, - }, - body: JSON.stringify({ ssid, pass }), - }); - if (response.ok) { - alert("Wifi updated. Restart to apply."); - await refreshDeviceInfo(); - } else { - alert(`Request failed: ${response.status}`); - } - } catch (error: unknown) { - alert(error instanceof Error ? error.message : "Unknown error"); - } - }; - - const sensorSnapshot = useMemo( - () => ({ - connection: connectionStatus.val, - message: lastMessage.val, - updated: lastUpdate.val, - }), - [tick], - ); - - const openRoast = () => { - const app = document.getElementById("app"); - if (!app) return; - app.innerHTML = ""; - van.add(app, roastApp()); - }; - - const openLogs = () => { - const app = document.getElementById("app"); - if (!app) return; - app.innerHTML = ""; - van.add(app, logsApp()); - window.history.pushState({}, "", "/logs"); - }; - - return ( -
- - -
- {activeTab === "overview" && ( - <> -
-

Connection

-

Status: {sensorSnapshot.connection}

-

Last update: {sensorSnapshot.updated?.toString() ?? "N/A"}

-
-
-

Sensors

-

ET: {sensorSnapshot.message?.ET ?? "N/A"}°C

-

BT: {sensorSnapshot.message?.BT ?? "N/A"}°C

-

Age: {sensorSnapshot.message?.sampleAgeMs ?? "N/A"} ms

-
- - )} - - {activeTab === "control" && ( - <> -
-

Profile

- -
-
-

PID

-
-

P

- setPidPFactor(Number.parseFloat(e.target.value) || 0)} /> -

I

- setPidIFactor(Number.parseFloat(e.target.value) || 0)} /> -

D

- setPidDFactor(Number.parseFloat(e.target.value) || 0)} /> -
-
- - )} - - {activeTab === "network" && ( - <> -
-

Versions

-

Web UI: {__APP_VERSION__}

-

Build: {new Date(__BUILD_TIMESTAMP__).toLocaleString()}

-

Firmware: {deviceInfo?.firmwareVersion ?? "N/A"}

-

Mode: {deviceInfo?.networkMode ?? "N/A"}

-

SSID: {deviceInfo?.ssid || "N/A"}

-

IP: {deviceInfo?.ip || "N/A"}

- {deviceInfoError &&

{deviceInfoError}

} - -
-
-

Wifi

-
-

SSID

- setSsid(e.target.value)} /> -

Password

- setPass(e.target.value)} /> -
- -
- - )} - -
-
- - -
-
-
-
- ); -} - -createRoot(document.getElementById("app") as HTMLElement).render( - - - , -); diff --git a/miniweb/src/style.css b/miniweb/src/style.css index 518319f..02d14a7 100644 --- a/miniweb/src/style.css +++ b/miniweb/src/style.css @@ -1,242 +1,230 @@ :root { - font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - --radius: 0.75rem; - --background: 220 23% 97%; - --foreground: 225 23% 12%; - --surface: 0 0% 100%; - --border: 222 22% 90%; - --input: 222 22% 90%; - --ring: 222 84% 58%; - --primary: 222 84% 55%; - --primary-foreground: 0 0% 100%; - --secondary: 220 24% 96%; - --secondary-foreground: 225 20% 20%; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; - background: hsl(var(--background)); - color: hsl(var(--foreground)); -} + /* === ShadCN design tokens === */ + --radius: 0.5rem; -h1, -h2, -p { - margin: 0; -} + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; -#app { - min-height: 100vh; -} + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; -.app-shell { - display: grid; - grid-template-columns: 280px 1fr; - min-height: 100vh; -} + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; -.sidebar { - border-right: 1px solid hsl(var(--border)); - background: linear-gradient(180deg, #0f172a 0%, #111827 100%); - color: #e5e7eb; - padding: 2rem 1rem; - display: flex; - flex-direction: column; - gap: 0.75rem; -} + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; -.sidebar h1 { - font-size: 1.5rem; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; } -.sidebar-subtitle { - color: #9ca3af; - margin-bottom: 0.75rem; +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; } - -.sidebar-tab { - all: unset; - cursor: pointer; - border: 1px solid transparent; - border-radius: var(--radius); - padding: 0.8rem 0.9rem; - background: rgba(255, 255, 255, 0.04); +a:hover { + color: #535bf2; } -.sidebar-tab:hover { - background: rgba(255, 255, 255, 0.08); +body { + margin: 0; + /*display: flex;*/ + place-items: center; + min-width: 320px; + min-height: 100vh; + background: hsl(var(--background)); + color: hsl(var(--foreground)); } -.sidebar-tab.is-active { - border-color: rgba(148, 163, 184, 0.45); - background: rgba(59, 130, 246, 0.22); +control_cluster { + place-content: align; + background-color: #ff0000; } -.sidebar-tab-title { - font-size: 0.95rem; - font-weight: 600; +h1 { + font-size: 3.2em; + line-height: 1.1; } -.sidebar-tab-subtitle { - font-size: 0.8rem; - color: #cbd5e1; +#app { + max-width: 1280px; + margin: 0 auto; + /*padding: 5rem;*/ + text-align: center; } -.content { - padding: 2rem; +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; } - -.content-header { - display: flex; - flex-direction: column; - gap: 0.25rem; - margin-bottom: 1rem; +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); } - -.content-header h2 { - font-size: 1.8rem; +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #3178c6aa); } -.content-header p { - color: #64748b; +.card { + padding: 2em; } -.dashboard-panel { - max-width: 900px; -} - -.section { - padding: 1.25rem; - margin-top: 1rem; - border: 1px solid hsl(var(--border)); - border-radius: var(--radius); - background: hsl(var(--surface)); - color: hsl(var(--foreground)); - display: flex; - flex-direction: column; - gap: 0.7rem; -} - -.settings-grid { - display: grid; - grid-template-columns: 140px 1fr; - align-items: center; - gap: 0.75rem; -} - -.action-row { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; +.read-the-docs { + color: #888; } +/* ------------------- ShadCN-style Button ------------------- */ button { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; + height: 2.5rem; padding: 0 1rem; font-size: 0.875rem; - font-weight: 600; + font-weight: 500; font-family: inherit; + border-radius: var(--radius); border: 1px solid hsl(var(--border)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); cursor: pointer; -} + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} button:hover { - filter: brightness(0.95); + background: hsl(var(--primary) / 0.9); } - button:disabled { opacity: 0.5; pointer-events: none; } - -input:not([type="range"]), -textarea, -select { +button:focus-visible { + outline: 2px solid transparent; + outline-offset: 2px; + box-shadow: 0 0 0 2px hsl(var(--ring)); +} +/* ------------------- ShadCN-style Inputs ------------------- */ +input:not([type="range"]) { + height: 2.5rem; width: 100%; - min-height: 2.5rem; - padding: 0.5rem 0.75rem; + padding: 0 0.75rem; font-size: 0.875rem; + border-radius: var(--radius); border: 1px solid hsl(var(--input)); - background: hsl(var(--surface)); + background: hsl(var(--background)); color: hsl(var(--foreground)); -} -input:not([type="range"]):focus, -textarea:focus, -select:focus { + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +input:not([type="range"]):focus { outline: none; border-color: hsl(var(--ring)); - box-shadow: 0 0 0 2px hsl(var(--ring) / 0.25); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.4); +} +/* ------------------- Card & Section ------------------- */ +.card, +.section { + padding: 1.5rem; + margin-top: 1.5rem; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); } +.section h2 { + margin-top: 0; + margin-bottom: 1rem; + font-size: 1.125rem; + font-weight: 600; +} +/* ------------------- Dark-mode token overrides ------------------- */ +@media (prefers-color-scheme: dark) { + :root { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; -.start-page { - max-width: 1080px; - margin: 0 auto; - padding: 2rem; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + } } +/* Style the slider */ input[type="range"] { -webkit-appearance: none; width: 220px; height: 6px; + /*background: #ddd;*/ border-radius: 3px; position: relative; margin: 20px 0; } +/* Remove default outline on focus */ input[type="range"]:focus { outline: none; } +/* Track styles */ input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: #ddd; border-radius: 3px; } +/* Thumb styles */ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; height: 20px; width: 20px; border-radius: 50%; - background: #007bff; + background: #007BFF; border: 2px solid #fff; cursor: pointer; - margin-top: -7px; + margin-top: -7px; /* Center thumb */ } -@media (max-width: 900px) { - .app-shell { - grid-template-columns: 1fr; - } +/* Disabled slider styles */ +input[type="range"]:disabled { + opacity: 0.5; + cursor: not-allowed; +} - .sidebar { - border-right: 0; - border-bottom: 1px solid hsl(var(--border)); - } +input[type="range"]:disabled::-webkit-slider-runnable-track { + background: #bbb; /* Lighter track for disabled state */ +} - .settings-grid { - grid-template-columns: 1fr; - } +input[type="range"]:disabled::-webkit-slider-thumb { + background: #888; /* Gray thumb for disabled state */ + border: 2px solid #ccc; + cursor: not-allowed; } diff --git a/miniweb/tsconfig.json b/miniweb/tsconfig.json index fac25fc..a4883f2 100644 --- a/miniweb/tsconfig.json +++ b/miniweb/tsconfig.json @@ -5,17 +5,20 @@ "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, + + /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, + + /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "jsx": "react-jsx" + "noUncheckedSideEffectImports": true }, "include": ["src"] } diff --git a/miniweb/vite.config.ts b/miniweb/vite.config.ts index 3ebabf4..8e0c7c8 100644 --- a/miniweb/vite.config.ts +++ b/miniweb/vite.config.ts @@ -1,4 +1,3 @@ -import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { VitePWA } from "vite-plugin-pwa"; import packageJson from "./package.json"; @@ -49,7 +48,7 @@ export default defineConfig(async () => ({ __BUILD_TIMESTAMP__: JSON.stringify(new Date().toISOString()), }, plugins: [ - react(), + // react(), // viteTsconfigPaths(), // svgrPlugin(), // viteCompression(), diff --git a/miniweb/yarn.lock b/miniweb/yarn.lock index 8be2048..f40c61a 100644 --- a/miniweb/yarn.lock +++ b/miniweb/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + "@apideck/better-ajv-errors@^0.3.1": version "0.3.6" resolved "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz" @@ -11,50 +19,50 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" - integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== +"@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.0", "@babel/code-frame@^7.26.2": + version "7.26.2" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== dependencies: - "@babel/helper-validator-identifier" "^7.28.5" + "@babel/helper-validator-identifier" "^7.25.9" js-tokens "^4.0.0" - picocolors "^1.1.1" + picocolors "^1.0.0" + +"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.9", "@babel/compat-data@^7.26.0": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz" + integrity sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g== -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.0", "@babel/compat-data@^7.28.6": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz" - integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== - -"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.0.0-0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.24.4", "@babel/core@^7.28.0", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz" - integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== - dependencies: - "@babel/code-frame" "^7.29.0" - "@babel/generator" "^7.29.0" - "@babel/helper-compilation-targets" "^7.28.6" - "@babel/helper-module-transforms" "^7.28.6" - "@babel/helpers" "^7.28.6" - "@babel/parser" "^7.29.0" - "@babel/template" "^7.28.6" - "@babel/traverse" "^7.29.0" - "@babel/types" "^7.29.0" - "@jridgewell/remapping" "^2.3.5" +"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.0.0-0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.24.4", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0": + version "7.26.0" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz" + integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.26.0" + "@babel/generator" "^7.26.0" + "@babel/helper-compilation-targets" "^7.25.9" + "@babel/helper-module-transforms" "^7.26.0" + "@babel/helpers" "^7.26.0" + "@babel/parser" "^7.26.0" + "@babel/template" "^7.25.9" + "@babel/traverse" "^7.25.9" + "@babel/types" "^7.26.0" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.29.0": - version "7.29.1" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz" - integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== +"@babel/generator@^7.26.0", "@babel/generator@^7.26.3": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz" + integrity sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ== dependencies: - "@babel/parser" "^7.29.0" - "@babel/types" "^7.29.0" - "@jridgewell/gen-mapping" "^0.3.12" - "@jridgewell/trace-mapping" "^0.3.28" + "@babel/parser" "^7.26.3" + "@babel/types" "^7.26.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" "@babel/helper-annotate-as-pure@^7.25.9": @@ -64,13 +72,13 @@ dependencies: "@babel/types" "^7.25.9" -"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9", "@babel/helper-compilation-targets@^7.28.6": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz" - integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== +"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz" + integrity sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ== dependencies: - "@babel/compat-data" "^7.28.6" - "@babel/helper-validator-option" "^7.27.1" + "@babel/compat-data" "^7.25.9" + "@babel/helper-validator-option" "^7.25.9" browserslist "^4.24.0" lru-cache "^5.1.1" semver "^6.3.1" @@ -108,11 +116,6 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" -"@babel/helper-globals@^7.28.0": - version "7.28.0" - resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz" - integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== - "@babel/helper-member-expression-to-functions@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz" @@ -121,22 +124,22 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.28.6": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz" - integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz" + integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== dependencies: - "@babel/traverse" "^7.28.6" - "@babel/types" "^7.28.6" + "@babel/traverse" "^7.25.9" + "@babel/types" "^7.25.9" -"@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0", "@babel/helper-module-transforms@^7.28.6": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz" - integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== +"@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0": + version "7.26.0" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz" + integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== dependencies: - "@babel/helper-module-imports" "^7.28.6" - "@babel/helper-validator-identifier" "^7.28.5" - "@babel/traverse" "^7.28.6" + "@babel/helper-module-imports" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + "@babel/traverse" "^7.25.9" "@babel/helper-optimise-call-expression@^7.25.9": version "7.25.9" @@ -145,10 +148,10 @@ dependencies: "@babel/types" "^7.25.9" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.27.1": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz" - integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz" + integrity sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw== "@babel/helper-remap-async-to-generator@^7.25.9": version "7.25.9" @@ -176,20 +179,20 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-string-parser@^7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" - integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== -"@babel/helper-validator-identifier@^7.25.9", "@babel/helper-validator-identifier@^7.28.5": - version "7.28.5" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz" - integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== -"@babel/helper-validator-option@^7.25.9", "@babel/helper-validator-option@^7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" - integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== +"@babel/helper-validator-option@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz" + integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== "@babel/helper-wrap-function@^7.25.9": version "7.25.9" @@ -200,20 +203,20 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helpers@^7.28.6": - version "7.29.2" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz" - integrity sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw== +"@babel/helpers@^7.26.0": + version "7.26.0" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz" + integrity sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw== dependencies: - "@babel/template" "^7.28.6" - "@babel/types" "^7.29.0" + "@babel/template" "^7.25.9" + "@babel/types" "^7.26.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": - version "7.29.2" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz" - integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== +"@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.3": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz" + integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA== dependencies: - "@babel/types" "^7.29.0" + "@babel/types" "^7.26.3" "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9": version "7.25.9" @@ -578,20 +581,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" -"@babel/plugin-transform-react-jsx-self@^7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz" - integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - -"@babel/plugin-transform-react-jsx-source@^7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz" - integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - "@babel/plugin-transform-regenerator@^7.25.9": version "7.25.9" resolved "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz" @@ -773,53 +762,48 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.25.9", "@babel/template@^7.28.6": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz" - integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== - dependencies: - "@babel/code-frame" "^7.28.6" - "@babel/parser" "^7.28.6" - "@babel/types" "^7.28.6" - -"@babel/traverse@^7.25.9", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz" - integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== - dependencies: - "@babel/code-frame" "^7.29.0" - "@babel/generator" "^7.29.0" - "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.29.0" - "@babel/template" "^7.28.6" - "@babel/types" "^7.29.0" +"@babel/template@^7.25.9": + version "7.25.9" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz" + integrity sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg== + dependencies: + "@babel/code-frame" "^7.25.9" + "@babel/parser" "^7.25.9" + "@babel/types" "^7.25.9" + +"@babel/traverse@^7.25.9": + version "7.26.4" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz" + integrity sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.3" + "@babel/parser" "^7.26.3" + "@babel/template" "^7.25.9" + "@babel/types" "^7.26.3" debug "^4.3.1" + globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.25.9", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.4.4": - version "7.29.0" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz" - integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== +"@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.26.3", "@babel/types@^7.4.4": + version "7.26.3" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz" + integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA== dependencies: - "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.28.5" + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" -"@esbuild/linux-x64@0.24.0": +"@esbuild/darwin-arm64@0.24.0": version "0.24.0" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz" + integrity sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw== -"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": - version "0.3.13" - resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz" - integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.8" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz" + integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/remapping@^2.3.5": - version "2.3.5" - resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz" - integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/resolve-uri@^3.1.0": @@ -827,6 +811,11 @@ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + "@jridgewell/source-map@^0.3.3": version "0.3.6" resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz" @@ -835,15 +824,15 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": version "1.5.0" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": - version "0.3.31" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -853,11 +842,6 @@ resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz" integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== -"@rolldown/pluginutils@1.0.0-beta.27": - version "1.0.0-beta.27" - resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz" - integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== - "@rollup/plugin-babel@^5.2.0": version "5.3.1" resolved "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz" @@ -912,8 +896,10 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-linux-x64-gnu@4.28.1": +"@rollup/rollup-darwin-arm64@4.28.1": version "4.28.1" + resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz" + integrity sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ== "@surma/rollup-plugin-off-main-thread@^2.2.3": version "2.2.3" @@ -925,39 +911,6 @@ magic-string "^0.25.0" string.prototype.matchall "^4.0.6" -"@types/babel__core@^7.1.9", "@types/babel__core@^7.20.5": - version "7.20.5" - resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" - integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== - dependencies: - "@babel/parser" "^7.20.7" - "@babel/types" "^7.20.7" - "@types/babel__generator" "*" - "@types/babel__template" "*" - "@types/babel__traverse" "*" - -"@types/babel__generator@*": - version "7.27.0" - resolved "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz" - integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== - dependencies: - "@babel/types" "^7.0.0" - -"@types/babel__template@*": - version "7.4.4" - resolved "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz" - integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== - dependencies: - "@babel/parser" "^7.1.0" - "@babel/types" "^7.0.0" - -"@types/babel__traverse@*": - version "7.28.0" - resolved "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz" - integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== - dependencies: - "@babel/types" "^7.28.2" - "@types/chartjs-plugin-trendline@^1.0.4": version "1.0.4" resolved "https://registry.npmjs.org/@types/chartjs-plugin-trendline/-/chartjs-plugin-trendline-1.0.4.tgz" @@ -985,18 +938,6 @@ resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== -"@vitejs/plugin-react@^4.4.1": - version "4.7.0" - resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz" - integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== - dependencies: - "@babel/core" "^7.28.0" - "@babel/plugin-transform-react-jsx-self" "^7.27.1" - "@babel/plugin-transform-react-jsx-source" "^7.27.1" - "@rolldown/pluginutils" "1.0.0-beta.27" - "@types/babel__core" "^7.20.5" - react-refresh "^0.17.0" - acorn@^8.8.2: version "8.14.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz" @@ -1487,6 +1428,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -1842,7 +1788,7 @@ jake@^10.8.5: filelist "^1.0.4" minimatch "^3.1.2" -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: +js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -1906,13 +1852,6 @@ lodash@^4.17.20: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.1.0: - version "1.4.0" - resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" @@ -2000,7 +1939,7 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -picocolors@^1.1.0, picocolors@^1.1.1: +picocolors@^1.0.0, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -2051,26 +1990,6 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -react-dom@^18.3.1: - version "18.3.1" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== - dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.2" - -react-refresh@^0.17.0: - version "0.17.0" - resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz" - integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== - -react@^18.3.1: - version "18.3.1" - resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== - dependencies: - loose-envify "^1.1.0" - reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.9" resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.9.tgz" @@ -2217,13 +2136,6 @@ safe-regex-test@^1.1.0: es-errors "^1.3.0" is-regex "^1.2.1" -scheduler@^0.23.2: - version "0.23.2" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" - semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" @@ -2586,7 +2498,7 @@ vite-plugin-pwa@^0.21.1: workbox-build "^7.3.0" workbox-window "^7.3.0" -"vite@^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", vite@^6.0.3: +"vite@^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", vite@^6.0.3: version "6.0.4" resolved "https://registry.npmjs.org/vite/-/vite-6.0.4.tgz" integrity sha512-zwlH6ar+6o6b4Wp+ydhtIKLrGM/LoqZzcdVmkGAFun0KHTzIzjh+h0kungEx7KJg/PYnC80I4TII9WkjciSR6Q== From 9160c01e58548700cada535687cabdf2afaaccfd Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 15:12:18 +0200 Subject: [PATCH 042/125] Modernize web UI with left-side tab navigation --- miniweb/src/logs.ts | 13 --- miniweb/src/main.ts | 175 ++++++++++++++++++++----------------- miniweb/src/style.css | 198 +++++++++++++++++++++++++----------------- 3 files changed, 210 insertions(+), 176 deletions(-) diff --git a/miniweb/src/logs.ts b/miniweb/src/logs.ts index 338bafd..9dc08f3 100644 --- a/miniweb/src/logs.ts +++ b/miniweb/src/logs.ts @@ -131,18 +131,5 @@ export const logsApp = () => { style: "width: 100%; min-height: 320px; font-family: monospace;", value: () => logText.val, }), - div( - { class: "section" }, - button({ - onclick: () => { - if (refreshTimerId != null) { - window.clearInterval(refreshTimerId); - refreshTimerId = null; - } - document.getElementById("app")!.innerHTML = ""; - window.location.href = "/"; - }, - }, "Back"), - ), ); }; diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index d5482bd..6e29144 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -2,8 +2,7 @@ import "./style.css"; import van from "vanjs-core"; import { roastApp } from "./roast"; import { logsApp } from "./logs"; -import { profile, ProfileControl } from "./profiling.ts"; -import { PIDController } from "./pid.ts"; +import { ProfileControl } from "./profiling.ts"; import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; import { getBasicAuthHeaderValue } from "./auth"; @@ -19,6 +18,8 @@ interface DeviceInfo { csrfToken?: string; } +type AppTab = "home" | "roast" | "logs" | "settings"; + const { button, div, input, p, span, h1, h2 } = van.tags; // State variables @@ -29,6 +30,7 @@ const pidDFactor = van.state(0.01); // Wifi const ssidField = van.state(""); const passField = van.state(""); +const activeTab = van.state("home"); // Versioning and network details const deviceInfo = van.state(null); @@ -91,38 +93,39 @@ const updateWifiSettings = async () => { } }; -// PID Configuration const PIDConfig = () => div( - "PID Factors", - p(), - "P:", - input({ - type: "number", - value: pidPFactor.val, - oninput: (e: Event) => { - pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - "I:", - input({ - type: "number", - value: pidIFactor.val, - oninput: (e: Event) => { - pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - "D:", - input({ - type: "number", - value: pidDFactor.val, - oninput: (e: Event) => { - pidDFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), + { class: "section" }, + h2("PID Factors"), + div( + { class: "form-grid" }, + p("P:"), + input({ + type: "number", + value: pidPFactor.val, + oninput: (e: Event) => { + pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + p("I:"), + input({ + type: "number", + value: pidIFactor.val, + oninput: (e: Event) => { + pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + p("D:"), + input({ + type: "number", + value: pidDFactor.val, + oninput: (e: Event) => { + pidDFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), ); -// Connection Status Display const ConnectionStatus = () => div( { class: "connection-status" }, @@ -132,21 +135,20 @@ const ConnectionStatus = () => style: () => `color: ${ connectionStatus.val === "Connected" - ? "green" + ? "#059669" : connectionStatus.val === "Error" - ? "red" - : "orange" + ? "#dc2626" + : "#d97706" }`, }, () => connectionStatus.val, ), ); -// Sensor Data Display const SensorData = () => div( - { class: "sensor-data" }, - "Current Readings:", + { class: "section" }, + h2("Current Readings"), p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), p("Sensor sample age: ", () => lastMessage.val?.sampleAgeMs ?? "N/A", " ms"), @@ -179,67 +181,76 @@ const VersionAndNetworkInfo = () => deviceInfoError.val, ) : null, - button({ onclick: refreshDeviceInfo }, "Refresh Info"), + button({ onclick: () => void refreshDeviceInfo() }, "Refresh Info"), ); -// Start page UI -const startPage = div( +const HomePanel = () => div( - { class: "start-page" }, h1("Yaeger Roaster Control"), + p({ class: "muted" }, "Modern command center for your roast workflow."), ConnectionStatus, SensorData, + ); + +const SettingsPanel = () => + div( VersionAndNetworkInfo, div({ class: "section" }, h2("Profile Selection"), ProfileControl), - div({ class: "section" }, h2("PID Settings"), PIDConfig), + PIDConfig, div( { class: "section" }, h2("Wifi Settings"), - p(), - "Wifi ssid:", - input({ - type: "text", - oninput: (e: Event) => { - ssidField.val = (e.target as HTMLInputElement).value; - }, - }), - p(), - "Wifi pass (if any)", - input({ - type: "password", - oninput: (e: Event) => { - passField.val = (e.target as HTMLInputElement).value; - }, - }), - p(), - button({ onclick: updateWifiSettings }, "Update Wifi"), - ), - div( - { class: "section" }, - button( - { - onclick: () => { - // Navigate to roast page - document.getElementById("app")!.innerHTML = ""; - van.add(document.getElementById("app")!, roastApp()); + div( + { class: "form-grid" }, + p("Wifi SSID"), + input({ + type: "text", + oninput: (e: Event) => { + ssidField.val = (e.target as HTMLInputElement).value; }, - }, - "Start Roasting", - ), - " ", - button( - { - onclick: () => { - document.getElementById("app")!.innerHTML = ""; - van.add(document.getElementById("app")!, logsApp()); - window.history.pushState({}, "", "/logs"); + }), + p("Wifi Password"), + input({ + type: "password", + oninput: (e: Event) => { + passField.val = (e.target as HTMLInputElement).value; }, - }, - "Logs", + }), ), + p(), + button({ onclick: () => void updateWifiSettings() }, "Update Wifi"), ), + ); + +const TabsNav = () => + div( + { class: "tabs-nav" }, + h2({ class: "tabs-title" }, "Yaeger"), + button({ class: () => `tab-btn ${activeTab.val === "home" ? "active" : ""}`, onclick: () => (activeTab.val = "home") }, "Home"), + button({ class: () => `tab-btn ${activeTab.val === "roast" ? "active" : ""}`, onclick: () => (activeTab.val = "roast") }, "Roast"), + button({ class: () => `tab-btn ${activeTab.val === "logs" ? "active" : ""}`, onclick: () => (activeTab.val = "logs") }, "Logs"), + button({ class: () => `tab-btn ${activeTab.val === "settings" ? "active" : ""}`, onclick: () => (activeTab.val = "settings") }, "Settings"), + ); + +const appLayout = div( + { class: "app-layout" }, + TabsNav, + div( + { class: "tab-content" }, + () => { + switch (activeTab.val) { + case "roast": + return roastApp(); + case "logs": + return logsApp(); + case "settings": + return SettingsPanel(); + case "home": + default: + return HomePanel(); + } + }, ), ); -// Attach UI to DOM -van.add(document.getElementById("app")!, startPage); +van.add(document.getElementById("app")!, appLayout); diff --git a/miniweb/src/style.css b/miniweb/src/style.css index 02d14a7..810ca58 100644 --- a/miniweb/src/style.css +++ b/miniweb/src/style.css @@ -12,106 +12,129 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - /* === ShadCN design tokens === */ - --radius: 0.5rem; - + --radius: 0.65rem; --background: 0 0% 100%; --foreground: 240 10% 3.9%; - --border: 240 5.9% 90%; --input: 240 5.9% 90%; - --ring: 240 5.9% 10%; - - --primary: 240 5.9% 10%; + --ring: 221 83% 53%; + --primary: 222 47% 11%; --primary-foreground: 0 0% 98%; - - --secondary: 240 4.8% 95.9%; + --secondary: 220 14% 96%; --secondary-foreground: 240 5.9% 10%; - --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - body { margin: 0; - /*display: flex;*/ - place-items: center; min-width: 320px; min-height: 100vh; - background: hsl(var(--background)); + background: radial-gradient(circle at top left, #dbeafe 0%, hsl(var(--background)) 40%); color: hsl(var(--foreground)); } -control_cluster { - place-content: align; - background-color: #ff0000; -} - h1 { - font-size: 3.2em; - line-height: 1.1; + font-size: 2rem; + line-height: 1.15; + margin: 0 0 0.5rem; } #app { - max-width: 1280px; - margin: 0 auto; - /*padding: 5rem;*/ - text-align: center; + width: min(1240px, 95vw); + margin: 2rem auto; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +.app-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1rem; + align-items: start; } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + +.tabs-nav { + position: sticky; + top: 1rem; + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: 1rem; + border: 1px solid hsl(var(--border)); + border-radius: 1rem; + background: linear-gradient(180deg, #0f172a, #111827); + box-shadow: 0 12px 34px rgba(2, 6, 23, 0.3); } -.logo.vanilla:hover { - filter: drop-shadow(0 0 2em #3178c6aa); + +.tabs-title { + color: #f8fafc; + margin: 0 0 0.75rem; + letter-spacing: 0.02em; } -.card { - padding: 2em; +.tab-btn { + justify-content: flex-start; + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.15); + background: rgba(30, 41, 59, 0.8); + color: #e2e8f0; +} + +.tab-btn:hover { + background: rgba(51, 65, 85, 0.95); +} + +.tab-btn.active { + background: linear-gradient(90deg, #2563eb, #0891b2); + color: #fff; + border-color: transparent; + box-shadow: 0 8px 20px rgba(37, 99, 235, 0.35); +} + +.tab-content { + padding: 1.25rem; + border-radius: 1rem; + border: 1px solid hsl(var(--border)); + background: hsl(var(--background)); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); } -.read-the-docs { - color: #888; +.muted { + color: #64748b; + margin-top: 0; +} + +.form-grid { + display: grid; + grid-template-columns: 120px 1fr; + align-items: center; + gap: 0.75rem; +} + +.form-grid p { + margin: 0; + font-weight: 500; } -/* ------------------- ShadCN-style Button ------------------- */ button { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; - height: 2.5rem; padding: 0 1rem; font-size: 0.875rem; - font-weight: 500; + font-weight: 600; font-family: inherit; - border-radius: var(--radius); border: 1px solid hsl(var(--border)); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); cursor: pointer; - - transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } button:hover { - background: hsl(var(--primary) / 0.9); + background: hsl(var(--primary) / 0.92); + transform: translateY(-1px); } button:disabled { opacity: 0.5; @@ -122,30 +145,35 @@ button:focus-visible { outline-offset: 2px; box-shadow: 0 0 0 2px hsl(var(--ring)); } -/* ------------------- ShadCN-style Inputs ------------------- */ -input:not([type="range"]) { + +input:not([type="range"]), +textarea { height: 2.5rem; width: 100%; padding: 0 0.75rem; font-size: 0.875rem; - border-radius: var(--radius); border: 1px solid hsl(var(--input)); background: hsl(var(--background)); color: hsl(var(--foreground)); - transition: border-color 0.15s ease, box-shadow 0.15s ease; } -input:not([type="range"]):focus { + +textarea { + height: auto; +} + +input:not([type="range"]):focus, +textarea:focus { outline: none; border-color: hsl(var(--ring)); - box-shadow: 0 0 0 2px hsl(var(--ring) / 0.4); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.35); } -/* ------------------- Card & Section ------------------- */ + .card, .section { - padding: 1.5rem; - margin-top: 1.5rem; + padding: 1.25rem; + margin-top: 1rem; border: 1px solid hsl(var(--border)); border-radius: var(--radius); background: hsl(var(--secondary)); @@ -154,77 +182,85 @@ input:not([type="range"]):focus { .section h2 { margin-top: 0; margin-bottom: 1rem; - font-size: 1.125rem; - font-weight: 600; + font-size: 1.05rem; +} + +@media (max-width: 880px) { + .app-layout { + grid-template-columns: 1fr; + } + + .tabs-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + } + + .tabs-title { + width: 100%; + } } -/* ------------------- Dark-mode token overrides ------------------- */ + @media (prefers-color-scheme: dark) { :root { --background: 240 10% 3.9%; --foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83%; - + --ring: 199 89% 48%; --primary: 0 0% 98%; --primary-foreground: 240 5.9% 10%; - --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; + } - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; + body { + background: radial-gradient(circle at top left, #111827 0%, hsl(var(--background)) 42%); } } -/* Style the slider */ +/* Range sliders used in roasting controls */ input[type="range"] { -webkit-appearance: none; width: 220px; height: 6px; - /*background: #ddd;*/ border-radius: 3px; position: relative; margin: 20px 0; } -/* Remove default outline on focus */ input[type="range"]:focus { outline: none; } -/* Track styles */ input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: #ddd; border-radius: 3px; } -/* Thumb styles */ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; height: 20px; width: 20px; border-radius: 50%; - background: #007BFF; + background: #007bff; border: 2px solid #fff; cursor: pointer; - margin-top: -7px; /* Center thumb */ + margin-top: -7px; } -/* Disabled slider styles */ input[type="range"]:disabled { opacity: 0.5; cursor: not-allowed; } input[type="range"]:disabled::-webkit-slider-runnable-track { - background: #bbb; /* Lighter track for disabled state */ + background: #bbb; } input[type="range"]:disabled::-webkit-slider-thumb { - background: #888; /* Gray thumb for disabled state */ + background: #888; border: 2px solid #ccc; cursor: not-allowed; } From 10e711a4ac8e96c54fa81660b79730d772eacb80 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 15:23:59 +0200 Subject: [PATCH 043/125] Move PID control loop from web UI to firmware --- miniweb/src/roast.ts | 54 +++++++++----------- src/CommandLoop.cpp | 115 ++++++++++++++++++++++++++++++++++++++++++- src/CommandLoop.h | 1 + src/main.cpp | 1 + 4 files changed, 140 insertions(+), 31 deletions(-) diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index 453d3e3..2e02d4e 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -10,7 +10,6 @@ import { Profile, } from "./model.ts"; import { getFormattedTimeDifference } from "./util.ts"; -import { PIDController } from "./pid.ts"; import { followProfile, followProfileEnabled, @@ -31,7 +30,6 @@ const setpoint = van.state(20); const pidPFactor = van.state(1.0); const pidIFactor = van.state(0.1); const pidDFactor = van.state(0.01); -var pid = new PIDController(1.0, 0.1, 0.01); // Chart.js setup const chartElement = canvas({ id: "liveChart" }); @@ -130,13 +128,13 @@ van.derive(() => { if (profileUpdate != undefined) { console.log("Updating setpoint from profile:", profileUpdate.setPoint); setpoint.val = profileUpdate.setPoint; + sendPidControlConfig(); if (profileUpdate.fanValue != undefined) { slider1Value.val = profileUpdate.fanValue! updateFanPower(profileUpdate.fanValue!) } } } - controlHeater(); } // Update state atomically @@ -232,6 +230,16 @@ function sendCommand(data: any) { socket?.send(msg); } +function sendPidControlConfig() { + sendCommand({ + id: 1, + command: "setPidControl", + setpoint: setpoint.val, + pidEnabled: pidEnabled.val, + pidTarget: tempTarget, + }); +} + var DownloadButton = () => { const shouldShowButton = van.derive(() => { const c = @@ -340,6 +348,7 @@ const SetpointControl = () => value: setpoint, oninput: (e: Event) => { setpoint.val = parseInt((e.target as HTMLInputElement).value, 10); + sendPidControlConfig(); }, }), ); @@ -386,6 +395,7 @@ const PIDConfig = () => value: tempTarget, onchange: (e: Event) => { tempTarget = (e.target as HTMLSelectElement).value; + sendPidControlConfig(); }, }, option({ value: "BT" }, "BT"), @@ -398,18 +408,18 @@ const PIDConfig = () => pidPFactor.val = tempP; pidIFactor.val = tempI; pidDFactor.val = tempD; - - pid = new PIDController( - pidPFactor.val, - pidIFactor.val, - pidDFactor.val, - ); + sendCommand({ + id: 1, + command: "setPreferences", + pidKp: pidPFactor.val, + pidKi: pidIFactor.val, + pidKd: pidDFactor.val, + }); console.log("New PID values set:", { P: pidPFactor.val, I: pidIFactor.val, D: pidDFactor.val, }); - console.log("PID:", JSON.stringify(pid)); }, }, "Apply pid", @@ -418,31 +428,15 @@ const PIDConfig = () => input({ type: "checkbox", checked: pidEnabled.val, - oninput: (e) => (pidEnabled.val = e.target.checked), + oninput: (e) => { + pidEnabled.val = e.target.checked; + sendPidControlConfig(); + }, }), "PID Enabled", ), ); -function controlHeater() { - let currentTemp: number; - if (tempTarget == "BT") { - currentTemp = state.val.currentState.lastMessage?.BT ?? 0; - } else { - currentTemp = state.val.currentState.lastMessage?.ET ?? 0; - } - const output = pid.compute(setpoint.val, currentTemp); - - // Clamp output to 0–100% range - const heaterPower = Math.min(100, Math.max(0, Math.round(output))); - - if (pidEnabled.val == false) { - return; - } - updateHeaterPower(heaterPower); - slider2Value.val = heaterPower; // Reflect change in the UI -} - // UI creation const createApp = () => div( div( diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 59719b6..3f4f665 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,17 @@ constexpr long ACTUATOR_MAX_VALUE = 100; unsigned long lastWebClientDisconnectMs = 0; unsigned long lastMutatingCommandMs = 0; bool webClientGraceActive = false; +constexpr unsigned long PID_UPDATE_INTERVAL_MS = 400; +unsigned long lastPidUpdateMs = 0; + +double pidIntegral = 0.0; +double pidPreviousError = 0.0; +bool pidHasPreviousError = false; + +double pidSetpoint = 20.0; +bool pidEnabled = true; +enum class PidTargetSensor { BT, ET }; +PidTargetSensor pidTarget = PidTargetSensor::BT; bool isMutatingCommand(const char *command) { if (command == NULL) { @@ -27,7 +39,8 @@ bool isMutatingCommand(const char *command) { return strncmp(command, "setBurner", 9) == 0 || strncmp(command, "setFan", 6) == 0 || - strncmp(command, "setPreferences", 14) == 0; + strncmp(command, "setPreferences", 14) == 0 || + strncmp(command, "setPidControl", 13) == 0; } bool enforceMutatingCommandAuth(AsyncWebSocketClient *client, @@ -107,8 +120,33 @@ bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, } } + if (strncmp(command, "setPidControl", 13) == 0) { + if (!doc["setpoint"].isNull() && !doc["setpoint"].is()) { + client->text("{\"error\":\"invalid schema: setpoint must be numeric\"}"); + return false; + } + if (!doc["pidEnabled"].isNull() && !doc["pidEnabled"].is()) { + client->text("{\"error\":\"invalid schema: pidEnabled must be boolean\"}"); + return false; + } + if (!doc["pidTarget"].isNull() && !doc["pidTarget"].is()) { + client->text("{\"error\":\"invalid schema: pidTarget must be string\"}"); + return false; + } + } + return true; } + +void resetPidState() { + pidIntegral = 0.0; + pidPreviousError = 0.0; + pidHasPreviousError = false; +} + +const char *pidTargetToString(PidTargetSensor target) { + return target == PidTargetSensor::ET ? "ET" : "BT"; +} } // namespace void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, @@ -208,6 +246,31 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, setFanSpeed(clampedVal); } + if (command != NULL && strncmp(command, "setPidControl", 13) == 0) { + if (!doc["setpoint"].isNull()) { + pidSetpoint = doc["setpoint"].as(); + preferences.putDouble("pidSetpoint", pidSetpoint); + } + if (!doc["pidEnabled"].isNull()) { + bool nextPidEnabled = doc["pidEnabled"].as(); + if (pidEnabled != nextPidEnabled) { + resetPidState(); + } + pidEnabled = nextPidEnabled; + preferences.putBool("pidEnabled", pidEnabled); + } + if (!doc["pidTarget"].isNull()) { + const char *target = doc["pidTarget"].as(); + if (target != NULL && strncmp(target, "ET", 2) == 0) { + pidTarget = PidTargetSensor::ET; + } else { + pidTarget = PidTargetSensor::BT; + } + preferences.putString("pidTarget", pidTargetToString(pidTarget)); + resetPidState(); + } + } + // Safeguard to prevent heater fuse blowout if (getHeaterPower() > 0 && getFanSpeed() <= 30) { setFanSpeed(30); @@ -248,6 +311,9 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["pidKi"] = preferences.getDouble("pidKi", 0.1); dataObj["pidKd"] = preferences.getDouble("pidKd", 0.01); dataObj["cooldownFanSpeed"] = preferences.getLong("coolFanSpeed", 65); + dataObj["setpoint"] = pidSetpoint; + dataObj["pidEnabled"] = pidEnabled; + dataObj["pidTarget"] = pidTargetToString(pidTarget); } if (command != NULL && strncmp(command, "getData", 7) == 0) { @@ -264,6 +330,9 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["sensorOk"] = gotReading; dataObj["BurnerVal"] = getHeaterPower(); dataObj["FanVal"] = getFanSpeed(); + dataObj["setpoint"] = pidSetpoint; + dataObj["pidEnabled"] = pidEnabled; + dataObj["pidTarget"] = pidTargetToString(pidTarget); } String response; @@ -282,6 +351,10 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, void setupMainLoop(AsyncWebSocket *ws) { preferences.begin("preferences"); + pidSetpoint = preferences.getDouble("pidSetpoint", 20.0); + const String configuredTarget = preferences.getString("pidTarget", "BT"); + pidTarget = configuredTarget == "ET" ? PidTargetSensor::ET : PidTargetSensor::BT; + pidEnabled = preferences.getBool("pidEnabled", true); ws->onEvent(onWsEvent); } @@ -304,3 +377,43 @@ void updateConnectionSafety(AsyncWebSocket *ws) { webClientGraceActive = false; log("No websocket clients after grace period, entering cooldown safety mode"); } + +void updatePidControl() { + unsigned long now = millis(); + if (now - lastPidUpdateMs < PID_UPDATE_INTERVAL_MS) { + return; + } + lastPidUpdateMs = now; + + if (!pidEnabled) { + return; + } + + float etbt[3]; + if (!getETBTReadings(etbt)) { + return; + } + + double currentTemp = pidTarget == PidTargetSensor::ET ? etbt[0] : etbt[1]; + double error = pidSetpoint - currentTemp; + + double kp = preferences.getDouble("pidKp", 1.0); + double ki = preferences.getDouble("pidKi", 0.1); + double kd = preferences.getDouble("pidKd", 0.01); + double dtSeconds = PID_UPDATE_INTERVAL_MS / 1000.0; + + pidIntegral += error * dtSeconds; + pidIntegral = std::clamp(pidIntegral, -100.0, 100.0); + + double derivative = 0.0; + if (pidHasPreviousError) { + derivative = (error - pidPreviousError) / dtSeconds; + } else { + pidHasPreviousError = true; + } + + double output = kp * error + ki * pidIntegral + kd * derivative; + long heaterPower = lround(std::clamp(output, 0.0, 100.0)); + setHeaterPower(heaterPower); + pidPreviousError = error; +} diff --git a/src/CommandLoop.h b/src/CommandLoop.h index e580556..89c6aad 100644 --- a/src/CommandLoop.h +++ b/src/CommandLoop.h @@ -3,3 +3,4 @@ void setupMainLoop(AsyncWebSocket *ws); void updateConnectionSafety(AsyncWebSocket *ws); +void updatePidControl(); diff --git a/src/main.cpp b/src/main.cpp index 3183990..772a66d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -123,6 +123,7 @@ void loop(void) { ws.cleanupClients(); updateConnectionSafety(&ws); takeReadings(); + updatePidControl(); } if (now - lastDisplayRefreshMs >= DISPLAY_REFRESH_INTERVAL_MS) { lastDisplayRefreshMs = now; From 6cd89e3bea533ad7d8c30894d972a8e80f3adcef Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 15:30:45 +0200 Subject: [PATCH 044/125] Disable PID/profile auto-start defaults --- miniweb/src/profiling.ts | 2 +- miniweb/src/roast.ts | 8 ++++++-- src/CommandLoop.cpp | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/miniweb/src/profiling.ts b/miniweb/src/profiling.ts index ce4113d..383f7ab 100644 --- a/miniweb/src/profiling.ts +++ b/miniweb/src/profiling.ts @@ -4,7 +4,7 @@ const { label, button, div, input, select, option, canvas, p, span } = van.tags; import { Profile, RoastState } from "./model"; export const profile = van.state(); -export const followProfileEnabled = van.state(true); +export const followProfileEnabled = van.state(false); const profileName = van.state(""); export function followProfile( diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index 2e02d4e..ac657c8 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -231,11 +231,13 @@ function sendCommand(data: any) { } function sendPidControlConfig() { + const shouldEnablePid = + pidEnabled.val && state.val.currentState.status == RoasterStatus.roasting; sendCommand({ id: 1, command: "setPidControl", setpoint: setpoint.val, - pidEnabled: pidEnabled.val, + pidEnabled: shouldEnablePid, pidTarget: tempTarget, }); } @@ -358,7 +360,7 @@ let tempI = pidIFactor.val; let tempD = pidDFactor.val; let tempTarget = "BT"; -const pidEnabled = van.state(true); +const pidEnabled = van.state(false); const PIDConfig = () => div( @@ -605,6 +607,7 @@ function toggleRoastStart() { }, profile: profile.val, }; + sendPidControlConfig(); break; case RoasterStatus.roasting: state.val = { @@ -618,6 +621,7 @@ function toggleRoastStart() { profile: state.val.profile, }, }; + sendPidControlConfig(); break; } } diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 3f4f665..848a7cc 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -354,7 +354,8 @@ void setupMainLoop(AsyncWebSocket *ws) { pidSetpoint = preferences.getDouble("pidSetpoint", 20.0); const String configuredTarget = preferences.getString("pidTarget", "BT"); pidTarget = configuredTarget == "ET" ? PidTargetSensor::ET : PidTargetSensor::BT; - pidEnabled = preferences.getBool("pidEnabled", true); + pidEnabled = false; + preferences.putBool("pidEnabled", false); ws->onEvent(onWsEvent); } From 587260a8826e41c0358b66631b1e9e0fdee44bfa Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 15:41:10 +0200 Subject: [PATCH 045/125] Add roast-page RoR and live PID diagnostic values --- miniweb/src/model.ts | 7 +++++++ miniweb/src/roast.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ src/CommandLoop.cpp | 18 ++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index 3fa02dc..ce87d3f 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -8,6 +8,13 @@ export type YaegerMessage = { sensorOk?: boolean; FanVal: number; BurnerVal: number; + btRoR?: number; + etRoR?: number; + pidCurrentTemp?: number; + pidError?: number; + pidIntegral?: number; + pidDerivative?: number; + pidOutput?: number; id: number; } diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index ac657c8..f691fd7 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -30,6 +30,8 @@ const setpoint = van.state(20); const pidPFactor = van.state(1.0); const pidIFactor = van.state(0.1); const pidDFactor = van.state(0.01); +const btRoR = van.state(null); +const etRoR = van.state(null); // Chart.js setup const chartElement = canvas({ id: "liveChart" }); @@ -115,6 +117,7 @@ van.derive(() => { // Update chart with new data updateChart(chart, newState.roast); + updateDisplayedRoR(); // Check profile following if ( @@ -242,6 +245,29 @@ function sendPidControlConfig() { }); } +function updateDisplayedRoR() { + const measurements = state.val.roast?.measurements ?? []; + if (measurements.length < 2) { + btRoR.val = null; + etRoR.val = null; + return; + } + + const latest = measurements[measurements.length - 1]; + const previous = measurements[measurements.length - 2]; + const elapsedSeconds = + (latest.timestamp.getTime() - previous.timestamp.getTime()) / 1000; + + if (elapsedSeconds <= 0) { + btRoR.val = null; + etRoR.val = null; + return; + } + + btRoR.val = ((latest.message.BT - previous.message.BT) / elapsedSeconds) * 60; + etRoR.val = ((latest.message.ET - previous.message.ET) / elapsedSeconds) * 60; +} + var DownloadButton = () => { const shouldShowButton = van.derive(() => { const c = @@ -327,6 +353,7 @@ const UploadRoastInput = () => { roast: jsonData, }; updateChart(chart, state.val.roast!); + updateDisplayedRoR(); } catch (error) { console.log("upload failed:", error); } @@ -573,6 +600,14 @@ const createApp = () => div( console.log("BT render:", currentMessage.val?.BT); return currentMessage.val?.BT ?? "N/A"; }, + " ", + "BT RoR: ", + () => btRoR.val?.toFixed(2) ?? "N/A", + " °C/min", + " ", + "ET RoR: ", + () => etRoR.val?.toFixed(2) ?? "N/A", + " °C/min", ), " ", p( @@ -585,6 +620,20 @@ const createApp = () => div( ), UploadRoastInput, p(), + div( + "PID current values: ", + "Temp ", + () => currentMessage.val?.pidCurrentTemp?.toFixed(2) ?? "N/A", + " | Error ", + () => currentMessage.val?.pidError?.toFixed(2) ?? "N/A", + " | Integral ", + () => currentMessage.val?.pidIntegral?.toFixed(2) ?? "N/A", + " | Derivative ", + () => currentMessage.val?.pidDerivative?.toFixed(2) ?? "N/A", + " | Output ", + () => currentMessage.val?.pidOutput?.toFixed(2) ?? "N/A", + ), + p(), PIDConfig, p(), ProfileControl, diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 848a7cc..516539b 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -26,6 +26,10 @@ unsigned long lastPidUpdateMs = 0; double pidIntegral = 0.0; double pidPreviousError = 0.0; bool pidHasPreviousError = false; +double pidCurrentTemp = NAN; +double pidError = 0.0; +double pidDerivative = 0.0; +double pidOutput = 0.0; double pidSetpoint = 20.0; bool pidEnabled = true; @@ -314,6 +318,11 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["setpoint"] = pidSetpoint; dataObj["pidEnabled"] = pidEnabled; dataObj["pidTarget"] = pidTargetToString(pidTarget); + dataObj["pidCurrentTemp"] = pidCurrentTemp; + dataObj["pidError"] = pidError; + dataObj["pidIntegral"] = pidIntegral; + dataObj["pidDerivative"] = pidDerivative; + dataObj["pidOutput"] = pidOutput; } if (command != NULL && strncmp(command, "getData", 7) == 0) { @@ -333,6 +342,11 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["setpoint"] = pidSetpoint; dataObj["pidEnabled"] = pidEnabled; dataObj["pidTarget"] = pidTargetToString(pidTarget); + dataObj["pidCurrentTemp"] = pidCurrentTemp; + dataObj["pidError"] = pidError; + dataObj["pidIntegral"] = pidIntegral; + dataObj["pidDerivative"] = pidDerivative; + dataObj["pidOutput"] = pidOutput; } String response; @@ -417,4 +431,8 @@ void updatePidControl() { long heaterPower = lround(std::clamp(output, 0.0, 100.0)); setHeaterPower(heaterPower); pidPreviousError = error; + pidCurrentTemp = currentTemp; + pidError = error; + pidDerivative = derivative; + pidOutput = output; } From c1f3352cfa0079353d44f84bd19df628731260e0 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 15:49:24 +0200 Subject: [PATCH 046/125] cleanup: remove unused firmware declarations in main --- src/main.cpp | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 772a66d..ba345ef 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,15 +1,11 @@ #include -#include #include #include //https://github.com/ayushsharma82/AsyncElegantOTA #include #include "AsyncWebSocket.h" #include "CommandLoop.h" -#include "HardwareSerial.h" -#include "IPAddress.h" -#include "WiFiType.h" #include "api.h" #include "config.h" #include "display.h" @@ -22,8 +18,6 @@ #define PIN 48 Adafruit_NeoPixel pixels(1, PIN); -// for ota -const char *host = "esp32 Roaster"; // Create AsyncWebServer object on port 80 /*WebServer server(80);*/ // Create a WebSocket object @@ -34,9 +28,6 @@ unsigned long lastFastTickMs = 0; constexpr unsigned long DISPLAY_REFRESH_INTERVAL_MS = 1000; unsigned long lastDisplayRefreshMs = 0; -void setupSimulation(AsyncWebSocket *ws); -void updateSimulation(); - unsigned long ota_progress_millis = 0; void onOTAStart() { // Log when OTA has started From 9a3905a4fb569c26037073a651c8c056af6ac3c2 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 15:54:08 +0200 Subject: [PATCH 047/125] optimize: make WebSerial logging optional to reduce firmware footprint --- platformio.ini | 1 - src/config.h | 4 ++++ src/logging.cpp | 34 ++++++++++++++++++++-------------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/platformio.ini b/platformio.ini index 8eaddf7..2c00d03 100644 --- a/platformio.ini +++ b/platformio.ini @@ -47,7 +47,6 @@ lib_deps = ; third-party package named "Network", which can break include resolution. ; https://github.com/denyssene/SimpleKalmanFilter https://bitbucket.org/David_Such/nexgen_filter.git#1.0.2 - ayushsharma82/WebSerial ESP32Async/AsyncTCP ESP32Async/ESPAsyncWebServer https://github.com/sebnil/Moving-Avarage-Filter--Arduino-Library- diff --git a/src/config.h b/src/config.h index f67aea8..550cd57 100644 --- a/src/config.h +++ b/src/config.h @@ -41,3 +41,7 @@ #ifndef ENABLE_LCD #define ENABLE_LCD 0 #endif + +#ifndef ENABLE_WEBSERIAL_LOGGING +#define ENABLE_WEBSERIAL_LOGGING 0 +#endif diff --git a/src/logging.cpp b/src/logging.cpp index a4deb35..348eebe 100644 --- a/src/logging.cpp +++ b/src/logging.cpp @@ -1,8 +1,10 @@ #include "logging.h" +#include "config.h" +#if ENABLE_WEBSERIAL_LOGGING #include +#endif namespace { -constexpr bool kEnableWebSerialLogging = false; constexpr size_t kLogBufferMaxChars = 32768; String gLogBuffer; @@ -22,30 +24,34 @@ void appendToLogBuffer(const char *message) { } void recvMsg(uint8_t *data, size_t len){ - if (kEnableWebSerialLogging) { - WebSerial.println("Received Data..."); - } - // TODO: can just map to char +#if ENABLE_WEBSERIAL_LOGGING String d = ""; - for(int i=0; i < len; i++){ + for(int i = 0; i < len; i++){ d += char(data[i]); } - if (kEnableWebSerialLogging) { - WebSerial.println(d); - } + WebSerial.println("Received Data..."); + WebSerial.println(d); +#else + (void)data; + (void)len; +#endif } void setupLogging(AsyncWebServer *server) { +#if ENABLE_WEBSERIAL_LOGGING WebSerial.begin(server); WebSerial.onMessage(recvMsg); +#else + (void)server; +#endif } void log(const char *message) { - Serial.println(message); + Serial.println(message); appendToLogBuffer(message); - if (kEnableWebSerialLogging) { + #if ENABLE_WEBSERIAL_LOGGING WebSerial.println(message); - } + #endif } void logf(const char *format, ...) { @@ -54,9 +60,9 @@ void logf(const char *format, ...) { va_start(args, format); vsnprintf(buf, sizeof(buf), format, args); va_end(args); - if (kEnableWebSerialLogging) { + #if ENABLE_WEBSERIAL_LOGGING WebSerial.print(buf); - } + #endif Serial.print(buf); appendToLogBuffer(buf); } From 1678ea742587b89a4bf623ef98789d241cd38616 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 16:03:05 +0200 Subject: [PATCH 048/125] Add Kalman-based simulated bean core temperature --- miniweb/src/main.ts | 1 + miniweb/src/model.ts | 1 + miniweb/src/roast.ts | 3 +++ src/CommandLoop.cpp | 1 + src/sensors.cpp | 61 ++++++++++++++++++++++++++++++++++++++++++++ src/sensors.h | 1 + 6 files changed, 68 insertions(+) diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index 6e29144..010e577 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -151,6 +151,7 @@ const SensorData = () => h2("Current Readings"), p("ET: ", () => lastMessage.val?.ET ?? "N/A", "°C"), p("BT: ", () => lastMessage.val?.BT ?? "N/A", "°C"), + p("Sim BT (core): ", () => lastMessage.val?.simBT ?? "N/A", "°C"), p("Sensor sample age: ", () => lastMessage.val?.sampleAgeMs ?? "N/A", " ms"), p("Sensor status: ", () => (lastMessage.val?.sensorOk ? "OK" : "BUSY/STALE")), p("Last update: ", () => lastUpdate.val?.toString() ?? "N/A"), diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index ce87d3f..c6f26c9 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -3,6 +3,7 @@ export type YaegerMessage = { ET: number; BT: number; + simBT?: number; Amb: number; sampleAgeMs?: number; sensorOk?: boolean; diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index f691fd7..4ea8e91 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -601,6 +601,9 @@ const createApp = () => div( return currentMessage.val?.BT ?? "N/A"; }, " ", + "Sim BT: ", + () => currentMessage.val?.simBT?.toFixed(1) ?? "N/A", + " ", "BT RoR: ", () => btRoR.val?.toFixed(2) ?? "N/A", " °C/min", diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 516539b..0ee584c 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -334,6 +334,7 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["type"] = "status"; dataObj["ET"] = gotReading ? etbt[0] : NAN; dataObj["BT"] = gotReading ? etbt[1] : NAN; + dataObj["simBT"] = getSimulatedInternalBeanTemp(); dataObj["Amb"] = gotReading ? etbt[2] : NAN; dataObj["sampleAgeMs"] = millis() - getLastSensorUpdateMs(); dataObj["sensorOk"] = gotReading; diff --git a/src/sensors.cpp b/src/sensors.cpp index 22be39e..f8af4e0 100644 --- a/src/sensors.cpp +++ b/src/sensors.cpp @@ -1,7 +1,9 @@ #include "FreeRTOS.h" #include "config.h" +#include "fan.h" #include "freertos/portmacro.h" #include "freertos/semphr.h" +#include "heater.h" #include "logging.h" #include "sensors.h" #include @@ -39,6 +41,22 @@ SemaphoreHandle_t mtx; StaticSemaphore_t mtx_buffer; float readings[3] = {0, 0, 0}; +float simulatedInternalBeanTemp = NAN; + +// 1D Kalman estimator for a "core bean" state: +// x_k = x_(k-1) + dt * (k_env*(ET-x) + k_heat*u_heat - k_fan*u_fan) + w_k +// z_k = BT + alpha * (ET-BT) + v_k +// +// The z_k formulation uses ET-BT differential as a proxy for inward heat flux, +// while control inputs model heater and fan influence on thermal dynamics. +constexpr float kCoreEnvCoupling = 0.010f; // 1/s +constexpr float kCoreHeaterGain = 0.030f; // °C/s at 100% heater +constexpr float kCoreFanCoolingGain = 0.025f; // °C/s at 100% fan +constexpr float kCoreDeltaWeight = 0.25f; // blend ET-BT differential +constexpr float kKalmanProcessNoiseBase = 0.06f; +constexpr float kKalmanProcessNoiseControlGain = 0.50f; +constexpr float kKalmanMeasurementNoise = 0.35f; +float simulatedBeanVariance = 1.0f; void takeETReadings(float dt); void takeBTReadings(float dt); @@ -90,6 +108,40 @@ void takeReadings() { logf("internal: %.2f\n", internal); #endif readings[2] = internal; + + bool sensorDataValid = exhaustSensorError == SENSOR_OK && beanSensorError == SENSOR_OK; + if (sensorDataValid) { + const float dtSeconds = dt / 1000.0f; + const float et = readings[0]; + const float bt = readings[1]; + const float heaterNorm = getHeaterPower() / 100.0f; + const float fanNorm = getFanSpeed() / 100.0f; + + if (isnan(simulatedInternalBeanTemp)) { + simulatedInternalBeanTemp = bt; + simulatedBeanVariance = 1.0f; + } + + const float controlInfluence = kCoreHeaterGain * heaterNorm - kCoreFanCoolingGain * fanNorm; + const float predicted = + simulatedInternalBeanTemp + + dtSeconds * (kCoreEnvCoupling * (et - simulatedInternalBeanTemp) + controlInfluence); + const float processNoise = kKalmanProcessNoiseBase + + kKalmanProcessNoiseControlGain * (fabsf(controlInfluence)); + float predictedVariance = simulatedBeanVariance + processNoise; + if (predictedVariance < 1e-4f) { + predictedVariance = 1e-4f; + } + + const float pseudoMeasurement = bt + kCoreDeltaWeight * (et - bt); + const float innovation = pseudoMeasurement - predicted; + const float innovationCovariance = predictedVariance + kKalmanMeasurementNoise; + const float kalmanGain = predictedVariance / innovationCovariance; + + simulatedInternalBeanTemp = predicted + kalmanGain * innovation; + simulatedBeanVariance = (1.0f - kalmanGain) * predictedVariance; + } + lastSensorUpdateMs = lastReadTime; xSemaphoreGiveRecursive(mtx); } @@ -199,6 +251,15 @@ bool getETBTReadings(float *readingsBuf) { return false; } +float getSimulatedInternalBeanTemp() { + if (xSemaphoreTakeRecursive(mtx, pdMS_TO_TICKS(5)) == pdTRUE) { + float simulated = simulatedInternalBeanTemp; + xSemaphoreGiveRecursive(mtx); + return simulated; + } + return NAN; +} + unsigned long getLastSensorUpdateMs() { return lastSensorUpdateMs; } diff --git a/src/sensors.h b/src/sensors.h index 2e330e0..fed17da 100644 --- a/src/sensors.h +++ b/src/sensors.h @@ -11,6 +11,7 @@ enum SensorErrorCode : uint8_t { void startSensors(); void takeReadings(); bool getETBTReadings(float *readings); +float getSimulatedInternalBeanTemp(); unsigned long getLastSensorUpdateMs(); SensorErrorCode getExhaustSensorError(); SensorErrorCode getBeanSensorError(); From ca80df0aabc62227093747e7d13c14921cb326db Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 16:34:27 +0200 Subject: [PATCH 049/125] Add PID autotune with per-target tuning options --- miniweb/src/model.ts | 3 + miniweb/src/roast.ts | 36 ++++- src/CommandLoop.cpp | 332 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 331 insertions(+), 40 deletions(-) diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index c6f26c9..8339d64 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -16,6 +16,9 @@ export type YaegerMessage = { pidIntegral?: number; pidDerivative?: number; pidOutput?: number; + pidTarget?: "BT" | "ET" | "simBT"; + pidTuneMethod?: "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; + pidAutotune?: boolean; id: number; } diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index 4ea8e91..deeeee9 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -234,14 +234,17 @@ function sendCommand(data: any) { } function sendPidControlConfig() { - const shouldEnablePid = - pidEnabled.val && state.val.currentState.status == RoasterStatus.roasting; + const isRoasting = state.val.currentState.status == RoasterStatus.roasting; + const shouldEnablePid = pidEnabled.val && isRoasting; + const shouldAutotune = pidAutotune.val && isRoasting; sendCommand({ id: 1, command: "setPidControl", setpoint: setpoint.val, pidEnabled: shouldEnablePid, pidTarget: tempTarget, + pidTuneMethod: tempMethod, + pidAutotune: shouldAutotune, }); } @@ -387,7 +390,9 @@ let tempI = pidIFactor.val; let tempD = pidDFactor.val; let tempTarget = "BT"; +let tempMethod = "ziegler-nichols"; const pidEnabled = van.state(false); +const pidAutotune = van.state(false); const PIDConfig = () => div( @@ -429,6 +434,22 @@ const PIDConfig = () => }, option({ value: "BT" }, "BT"), option({ value: "ET" }, "ET"), + option({ value: "simBT" }, "Sim BT"), + ), + p(), + "Method:", + select( + { + value: tempMethod, + onchange: (e: Event) => { + tempMethod = (e.target as HTMLSelectElement).value; + sendPidControlConfig(); + }, + }, + option({ value: "ziegler-nichols" }, "Ziegler-Nichols"), + option({ value: "tyreus-luyben" }, "Tyreus-Luyben"), + option({ value: "pessen-integral" }, "Pessen Integral"), + option({ value: "no-overshoot" }, "No overshoot"), ), p(), button( @@ -440,6 +461,7 @@ const PIDConfig = () => sendCommand({ id: 1, command: "setPreferences", + pidTarget: tempTarget, pidKp: pidPFactor.val, pidKi: pidIFactor.val, pidKd: pidDFactor.val, @@ -464,6 +486,16 @@ const PIDConfig = () => }), "PID Enabled", ), + p(), + button( + { + onclick: () => { + pidAutotune.val = !pidAutotune.val; + sendPidControlConfig(); + }, + }, + () => (pidAutotune.val ? "Stop autotune" : "Start autotune"), + ), ); // UI creation diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 0ee584c..99111af 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -33,22 +33,32 @@ double pidOutput = 0.0; double pidSetpoint = 20.0; bool pidEnabled = true; -enum class PidTargetSensor { BT, ET }; +enum class PidTargetSensor { BT, ET, SIM_BT }; PidTargetSensor pidTarget = PidTargetSensor::BT; +enum class PidTuneMethod { ZIEGLER_NICHOLS, TYREUS_LUYBEN, PESSEN_INTEGRAL, NO_OVERSHOOT }; +PidTuneMethod pidTuneMethod = PidTuneMethod::ZIEGLER_NICHOLS; +bool pidAutotuneActive = false; +bool pidAutotuneRelayHigh = true; +double pidAutotunePeakHigh = NAN; +double pidAutotunePeakLow = NAN; +unsigned long pidAutotuneLastCrossingMs = 0; +double pidAutotuneHalfCycleSecondsSum = 0.0; +int pidAutotuneHalfCycleCount = 0; +int pidAutotuneCrossings = 0; +constexpr double PID_AUTOTUNE_RELAY_OUTPUT_HIGH = 60.0; +constexpr double PID_AUTOTUNE_RELAY_OUTPUT_LOW = 20.0; +constexpr int PID_AUTOTUNE_MIN_CROSSINGS = 8; bool isMutatingCommand(const char *command) { if (command == NULL) { return false; } - return strncmp(command, "setBurner", 9) == 0 || - strncmp(command, "setFan", 6) == 0 || - strncmp(command, "setPreferences", 14) == 0 || - strncmp(command, "setPidControl", 13) == 0; + return strncmp(command, "setBurner", 9) == 0 || strncmp(command, "setFan", 6) == 0 || + strncmp(command, "setPreferences", 14) == 0 || strncmp(command, "setPidControl", 13) == 0; } -bool enforceMutatingCommandAuth(AsyncWebSocketClient *client, - JsonDocument &doc) { +bool enforceMutatingCommandAuth(AsyncWebSocketClient *client, JsonDocument &doc) { const char *authToken = doc["authToken"] | ""; if (!isValidAdminToken(authToken)) { client->text("{\"error\":\"unauthorized mutating command\"}"); @@ -75,9 +85,7 @@ long clampActuatorValue(long value) { return value; } -bool isActuatorValueInRange(long value) { - return value >= ACTUATOR_MIN_VALUE && value <= ACTUATOR_MAX_VALUE; -} +bool isActuatorValueInRange(long value) { return value >= ACTUATOR_MIN_VALUE && value <= ACTUATOR_MAX_VALUE; } bool parseActuatorValue(JsonDocument &doc, const char *fieldName, long &valueOut) { JsonVariant field = doc[fieldName]; @@ -91,8 +99,93 @@ bool parseActuatorValue(JsonDocument &doc, const char *fieldName, long &valueOut return true; } -bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, - const char *command) { +const char *pidTargetToString(PidTargetSensor target) { + switch (target) { + case PidTargetSensor::ET: + return "ET"; + case PidTargetSensor::SIM_BT: + return "simBT"; + default: + return "BT"; + } +} + +bool parsePidTarget(const char *target, PidTargetSensor &targetOut) { + if (target == NULL) { + return false; + } + if (strncmp(target, "ET", 2) == 0) { + targetOut = PidTargetSensor::ET; + return true; + } + if (strncmp(target, "simBT", 5) == 0) { + targetOut = PidTargetSensor::SIM_BT; + return true; + } + if (strncmp(target, "BT", 2) == 0) { + targetOut = PidTargetSensor::BT; + return true; + } + return false; +} + +const char *pidMethodToString(PidTuneMethod method) { + switch (method) { + case PidTuneMethod::TYREUS_LUYBEN: + return "tyreus-luyben"; + case PidTuneMethod::PESSEN_INTEGRAL: + return "pessen-integral"; + case PidTuneMethod::NO_OVERSHOOT: + return "no-overshoot"; + default: + return "ziegler-nichols"; + } +} + +bool parsePidMethod(const char *methodValue, PidTuneMethod &methodOut) { + if (methodValue == NULL) { + return false; + } + if (strncmp(methodValue, "tyreus-luyben", 13) == 0) { + methodOut = PidTuneMethod::TYREUS_LUYBEN; + return true; + } + if (strncmp(methodValue, "pessen-integral", 15) == 0) { + methodOut = PidTuneMethod::PESSEN_INTEGRAL; + return true; + } + if (strncmp(methodValue, "no-overshoot", 12) == 0) { + methodOut = PidTuneMethod::NO_OVERSHOOT; + return true; + } + if (strncmp(methodValue, "ziegler-nichols", 15) == 0) { + methodOut = PidTuneMethod::ZIEGLER_NICHOLS; + return true; + } + return false; +} + +String pidTargetPreferenceKey(const char *baseKey, PidTargetSensor target) { + String key(baseKey); + key += "_"; + key += pidTargetToString(target); + return key; +} + +double getPidGain(const char *baseKey, PidTargetSensor target, double defaultValue) { + String targetKey = pidTargetPreferenceKey(baseKey, target); + if (preferences.isKey(targetKey.c_str())) { + return preferences.getDouble(targetKey.c_str(), defaultValue); + } + return preferences.getDouble(baseKey, defaultValue); +} + +void setPidGain(const char *baseKey, PidTargetSensor target, double value) { + String targetKey = pidTargetPreferenceKey(baseKey, target); + preferences.putDouble(targetKey.c_str(), value); +} + +bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, const char *command) { if (command == NULL) { return true; } @@ -118,6 +211,10 @@ bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, client->text("{\"error\":\"invalid schema: pidKd must be numeric\"}"); return false; } + if (!doc["pidTarget"].isNull() && !doc["pidTarget"].is()) { + client->text("{\"error\":\"invalid schema: pidTarget must be string\"}"); + return false; + } if (!doc["cooldownFanSpeed"].isNull() && !doc["cooldownFanSpeed"].is()) { client->text("{\"error\":\"invalid schema: cooldownFanSpeed must be numeric\"}"); return false; @@ -137,6 +234,14 @@ bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, client->text("{\"error\":\"invalid schema: pidTarget must be string\"}"); return false; } + if (!doc["pidAutotune"].isNull() && !doc["pidAutotune"].is()) { + client->text("{\"error\":\"invalid schema: pidAutotune must be boolean\"}"); + return false; + } + if (!doc["pidTuneMethod"].isNull() && !doc["pidTuneMethod"].is()) { + client->text("{\"error\":\"invalid schema: pidTuneMethod must be string\"}"); + return false; + } } return true; @@ -148,13 +253,74 @@ void resetPidState() { pidHasPreviousError = false; } -const char *pidTargetToString(PidTargetSensor target) { - return target == PidTargetSensor::ET ? "ET" : "BT"; +void startPidAutotune() { + pidAutotuneActive = true; + pidAutotuneRelayHigh = true; + pidAutotunePeakHigh = NAN; + pidAutotunePeakLow = NAN; + pidAutotuneLastCrossingMs = 0; + pidAutotuneHalfCycleSecondsSum = 0.0; + pidAutotuneHalfCycleCount = 0; + pidAutotuneCrossings = 0; + pidEnabled = false; + preferences.putBool("pidEnabled", false); +} + +void stopPidAutotune() { + pidAutotuneActive = false; + setHeaterPower(0); +} + +double readPidTargetTemp(PidTargetSensor target, const float *etbt) { + if (target == PidTargetSensor::ET) { + return etbt[0]; + } + if (target == PidTargetSensor::SIM_BT) { + return getSimulatedInternalBeanTemp(); + } + return etbt[1]; +} + +void applyAutotunedPidGains(double ku, double puSeconds) { + if (puSeconds <= 0.0 || ku <= 0.0) { + return; + } + + double kp = 0.6 * ku; + double ki = 1.2 * ku / puSeconds; + double kd = 0.075 * ku * puSeconds; + + switch (pidTuneMethod) { + case PidTuneMethod::TYREUS_LUYBEN: { + kp = 0.454 * ku; + const double ti = 2.2 * puSeconds; + const double td = puSeconds / 6.3; + ki = kp / ti; + kd = kp * td; + break; + } + case PidTuneMethod::PESSEN_INTEGRAL: + kp = 0.7 * ku; + ki = 1.75 * ku / puSeconds; + kd = 0.105 * ku * puSeconds; + break; + case PidTuneMethod::NO_OVERSHOOT: + kp = 0.2 * ku; + ki = 0.4 * ku / puSeconds; + kd = ku * puSeconds / 15.0; + break; + default: + break; + } + + setPidGain("pidKp", pidTarget, kp); + setPidGain("pidKi", pidTarget, ki); + setPidGain("pidKd", pidTarget, kd); } } // namespace -void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, - AwsEventType type, void *arg, uint8_t *data, size_t len) { +void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, + uint8_t *data, size_t len) { switch (type) { case WS_EVT_CONNECT: @@ -204,7 +370,6 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } } - // Get BurnerVal from Artisan over Websocket long burnerVal = 0; if (parseActuatorValue(doc, "BurnerVal", burnerVal)) { if (!isActuatorValueInRange(burnerVal)) { @@ -264,34 +429,59 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, preferences.putBool("pidEnabled", pidEnabled); } if (!doc["pidTarget"].isNull()) { - const char *target = doc["pidTarget"].as(); - if (target != NULL && strncmp(target, "ET", 2) == 0) { - pidTarget = PidTargetSensor::ET; + PidTargetSensor parsedTarget; + if (parsePidTarget(doc["pidTarget"].as(), parsedTarget)) { + pidTarget = parsedTarget; + preferences.putString("pidTarget", pidTargetToString(pidTarget)); + resetPidState(); + } else { + client->text("{\"error\":\"invalid pidTarget\"}"); + return; + } + } + if (!doc["pidTuneMethod"].isNull()) { + PidTuneMethod parsedMethod; + if (parsePidMethod(doc["pidTuneMethod"].as(), parsedMethod)) { + pidTuneMethod = parsedMethod; + preferences.putString("pidTuneMethod", pidMethodToString(pidTuneMethod)); + } else { + client->text("{\"error\":\"invalid pidTuneMethod\"}"); + return; + } + } + if (!doc["pidAutotune"].isNull()) { + bool shouldAutotune = doc["pidAutotune"].as(); + if (shouldAutotune) { + startPidAutotune(); } else { - pidTarget = PidTargetSensor::BT; + stopPidAutotune(); } - preferences.putString("pidTarget", pidTargetToString(pidTarget)); - resetPidState(); } } - // Safeguard to prevent heater fuse blowout if (getHeaterPower() > 0 && getFanSpeed() <= 30) { setFanSpeed(30); } if (command != NULL && strncmp(command, "setPreferences", 14) == 0) { + PidTargetSensor preferenceTarget = pidTarget; + if (!doc["pidTarget"].isNull()) { + if (!parsePidTarget(doc["pidTarget"].as(), preferenceTarget)) { + client->text("{\"error\":\"invalid pidTarget\"}"); + return; + } + } if (!doc["pidKp"].isNull()) { double pidKp = doc["pidKp"].as(); - preferences.putDouble("pidKp", pidKp); + setPidGain("pidKp", preferenceTarget, pidKp); } if (!doc["pidKi"].isNull()) { double pidKi = doc["pidKi"].as(); - preferences.putDouble("pidKi", pidKi); + setPidGain("pidKi", preferenceTarget, pidKi); } if (!doc["pidKd"].isNull()) { double pidKd = doc["pidKd"].as(); - preferences.putDouble("pidKd", pidKd); + setPidGain("pidKd", preferenceTarget, pidKd); } if (!doc["cooldownFanSpeed"].isNull()) { long cooldownFanSpeed = doc["cooldownFanSpeed"].as(); @@ -305,19 +495,20 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, } if (command != NULL && - (strncmp(command, "setPreferences", 14) == 0 || - strncmp(command, "getPreferences", 14) == 0)) { + (strncmp(command, "setPreferences", 14) == 0 || strncmp(command, "getPreferences", 14) == 0)) { JsonObject root = doc.to(); JsonObject dataObj = root["data"].to(); root["id"] = ln_id; dataObj["type"] = "preferences"; - dataObj["pidKp"] = preferences.getDouble("pidKp", 1.0); - dataObj["pidKi"] = preferences.getDouble("pidKi", 0.1); - dataObj["pidKd"] = preferences.getDouble("pidKd", 0.01); + dataObj["pidKp"] = getPidGain("pidKp", pidTarget, 1.0); + dataObj["pidKi"] = getPidGain("pidKi", pidTarget, 0.1); + dataObj["pidKd"] = getPidGain("pidKd", pidTarget, 0.01); dataObj["cooldownFanSpeed"] = preferences.getLong("coolFanSpeed", 65); dataObj["setpoint"] = pidSetpoint; dataObj["pidEnabled"] = pidEnabled; dataObj["pidTarget"] = pidTargetToString(pidTarget); + dataObj["pidTuneMethod"] = pidMethodToString(pidTuneMethod); + dataObj["pidAutotune"] = pidAutotuneActive; dataObj["pidCurrentTemp"] = pidCurrentTemp; dataObj["pidError"] = pidError; dataObj["pidIntegral"] = pidIntegral; @@ -343,6 +534,8 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, dataObj["setpoint"] = pidSetpoint; dataObj["pidEnabled"] = pidEnabled; dataObj["pidTarget"] = pidTargetToString(pidTarget); + dataObj["pidTuneMethod"] = pidMethodToString(pidTuneMethod); + dataObj["pidAutotune"] = pidAutotuneActive; dataObj["pidCurrentTemp"] = pidCurrentTemp; dataObj["pidError"] = pidError; dataObj["pidIntegral"] = pidIntegral; @@ -368,9 +561,22 @@ void setupMainLoop(AsyncWebSocket *ws) { preferences.begin("preferences"); pidSetpoint = preferences.getDouble("pidSetpoint", 20.0); const String configuredTarget = preferences.getString("pidTarget", "BT"); - pidTarget = configuredTarget == "ET" ? PidTargetSensor::ET : PidTargetSensor::BT; + PidTargetSensor configuredPidTarget; + if (parsePidTarget(configuredTarget.c_str(), configuredPidTarget)) { + pidTarget = configuredPidTarget; + } else { + pidTarget = PidTargetSensor::BT; + } + const String configuredMethod = preferences.getString("pidTuneMethod", "ziegler-nichols"); + PidTuneMethod configuredPidMethod; + if (parsePidMethod(configuredMethod.c_str(), configuredPidMethod)) { + pidTuneMethod = configuredPidMethod; + } else { + pidTuneMethod = PidTuneMethod::ZIEGLER_NICHOLS; + } pidEnabled = false; preferences.putBool("pidEnabled", false); + pidAutotuneActive = false; ws->onEvent(onWsEvent); } @@ -401,7 +607,7 @@ void updatePidControl() { } lastPidUpdateMs = now; - if (!pidEnabled) { + if (!pidEnabled && !pidAutotuneActive) { return; } @@ -410,12 +616,62 @@ void updatePidControl() { return; } - double currentTemp = pidTarget == PidTargetSensor::ET ? etbt[0] : etbt[1]; + double currentTemp = readPidTargetTemp(pidTarget, etbt); + if (isnan(currentTemp)) { + return; + } + + if (pidAutotuneActive) { + if (pidAutotuneRelayHigh) { + if (isnan(pidAutotunePeakHigh) || currentTemp > pidAutotunePeakHigh) { + pidAutotunePeakHigh = currentTemp; + } + setHeaterPower(lround(PID_AUTOTUNE_RELAY_OUTPUT_HIGH)); + if (currentTemp >= pidSetpoint) { + pidAutotuneRelayHigh = false; + if (pidAutotuneLastCrossingMs != 0) { + pidAutotuneHalfCycleSecondsSum += (now - pidAutotuneLastCrossingMs) / 1000.0; + pidAutotuneHalfCycleCount++; + } + pidAutotuneLastCrossingMs = now; + pidAutotuneCrossings++; + } + } else { + if (isnan(pidAutotunePeakLow) || currentTemp < pidAutotunePeakLow) { + pidAutotunePeakLow = currentTemp; + } + setHeaterPower(lround(PID_AUTOTUNE_RELAY_OUTPUT_LOW)); + if (currentTemp <= pidSetpoint) { + pidAutotuneRelayHigh = true; + if (pidAutotuneLastCrossingMs != 0) { + pidAutotuneHalfCycleSecondsSum += (now - pidAutotuneLastCrossingMs) / 1000.0; + pidAutotuneHalfCycleCount++; + } + pidAutotuneLastCrossingMs = now; + pidAutotuneCrossings++; + } + } + + if (pidAutotuneCrossings >= PID_AUTOTUNE_MIN_CROSSINGS && pidAutotuneHalfCycleCount > 0 && + !isnan(pidAutotunePeakHigh) && !isnan(pidAutotunePeakLow) && pidAutotunePeakHigh > pidAutotunePeakLow) { + const double oscillationAmplitude = (pidAutotunePeakHigh - pidAutotunePeakLow) / 2.0; + const double relayAmplitude = (PID_AUTOTUNE_RELAY_OUTPUT_HIGH - PID_AUTOTUNE_RELAY_OUTPUT_LOW) / 2.0; + const double ku = (4.0 * relayAmplitude) / (M_PI * oscillationAmplitude); + const double puSeconds = 2.0 * (pidAutotuneHalfCycleSecondsSum / pidAutotuneHalfCycleCount); + applyAutotunedPidGains(ku, puSeconds); + stopPidAutotune(); + resetPidState(); + } + + pidCurrentTemp = currentTemp; + return; + } + double error = pidSetpoint - currentTemp; - double kp = preferences.getDouble("pidKp", 1.0); - double ki = preferences.getDouble("pidKi", 0.1); - double kd = preferences.getDouble("pidKd", 0.01); + double kp = getPidGain("pidKp", pidTarget, 1.0); + double ki = getPidGain("pidKi", pidTarget, 0.1); + double kd = getPidGain("pidKd", pidTarget, 0.01); double dtSeconds = PID_UPDATE_INTERVAL_MS / 1000.0; pidIntegral += error * dtSeconds; From 68cde657813ad1ce603b296d9c773fd131ce52a4 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 16:42:35 +0200 Subject: [PATCH 050/125] Move PID autotune controls to dedicated tab --- miniweb/src/autotune.ts | 157 ++++++++++++++++++++++++++++++++++++++++ miniweb/src/main.ts | 6 +- miniweb/src/model.ts | 1 + miniweb/src/roast.ts | 30 -------- 4 files changed, 163 insertions(+), 31 deletions(-) create mode 100644 miniweb/src/autotune.ts diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts new file mode 100644 index 0000000..7f12b84 --- /dev/null +++ b/miniweb/src/autotune.ts @@ -0,0 +1,157 @@ +import van from "vanjs-core"; +import { getAdminSecret } from "./auth"; +import { lastMessage, socket } from "./websocket"; + +const { button, div, h2, input, option, p, select } = van.tags; + +type PidTarget = "BT" | "ET" | "simBT"; +type PidMethod = "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; + +const target = van.state("BT"); +const method = van.state("ziegler-nichols"); +const setpoint = van.state(20); +const kp = van.state(1.0); +const ki = van.state(0.1); +const kd = van.state(0.01); +const autotuneActive = van.state(false); + +function sendCommand(data: any) { + const authToken = getAdminSecret(); + socket?.send(JSON.stringify({ ...data, authToken })); +} + +function syncFromMessage() { + const msg = lastMessage.val; + if (!msg) { + return; + } + if (msg.pidTarget) { + target.val = msg.pidTarget; + } + if (msg.pidTuneMethod) { + method.val = msg.pidTuneMethod; + } + if (typeof msg.setpoint === "number") { + setpoint.val = msg.setpoint; + } + if (typeof msg.pidAutotune === "boolean") { + autotuneActive.val = msg.pidAutotune; + } +} + +van.derive(syncFromMessage); + +function applyTargetPid() { + sendCommand({ + id: 1, + command: "setPreferences", + pidTarget: target.val, + pidKp: kp.val, + pidKi: ki.val, + pidKd: kd.val, + }); +} + +function startAutotune() { + sendCommand({ + id: 1, + command: "setPidControl", + pidEnabled: false, + pidTarget: target.val, + pidTuneMethod: method.val, + setpoint: setpoint.val, + pidAutotune: true, + }); + autotuneActive.val = true; +} + +function stopAutotune() { + sendCommand({ + id: 1, + command: "setPidControl", + pidAutotune: false, + }); + autotuneActive.val = false; +} + +export const autotuneApp = () => + div( + { class: "section" }, + h2("PID Autotune"), + p("Use this page to tune PID without starting a roast session."), + p( + "Target", + select( + { + value: target, + onchange: (e: Event) => { + target.val = (e.target as HTMLSelectElement).value as PidTarget; + }, + }, + option({ value: "BT" }, "BT"), + option({ value: "ET" }, "ET"), + option({ value: "simBT" }, "Sim BT"), + ), + ), + p( + "Method", + select( + { + value: method, + onchange: (e: Event) => { + method.val = (e.target as HTMLSelectElement).value as PidMethod; + }, + }, + option({ value: "ziegler-nichols" }, "Ziegler-Nichols"), + option({ value: "tyreus-luyben" }, "Tyreus-Luyben"), + option({ value: "pessen-integral" }, "Pessen Integral"), + option({ value: "no-overshoot" }, "No overshoot"), + ), + ), + p( + "Setpoint (°C)", + input({ + type: "number", + value: setpoint, + oninput: (e: Event) => { + setpoint.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), + p( + "Kp", + input({ + type: "number", + value: kp, + oninput: (e: Event) => { + kp.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), + p( + "Ki", + input({ + type: "number", + value: ki, + oninput: (e: Event) => { + ki.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), + p( + "Kd", + input({ + type: "number", + value: kd, + oninput: (e: Event) => { + kd.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), + button({ onclick: applyTargetPid }, "Save PID for target"), + " ", + button({ onclick: startAutotune, disabled: () => autotuneActive.val }, "Start autotune"), + " ", + button({ onclick: stopAutotune, disabled: () => !autotuneActive.val }, "Stop autotune"), + p("Autotune status: ", () => (autotuneActive.val ? "Running" : "Idle")), + ); diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index 010e577..df6dea4 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -1,6 +1,7 @@ import "./style.css"; import van from "vanjs-core"; import { roastApp } from "./roast"; +import { autotuneApp } from "./autotune"; import { logsApp } from "./logs"; import { ProfileControl } from "./profiling.ts"; import { connectionStatus, lastMessage, lastUpdate } from "./websocket"; @@ -18,7 +19,7 @@ interface DeviceInfo { csrfToken?: string; } -type AppTab = "home" | "roast" | "logs" | "settings"; +type AppTab = "home" | "roast" | "autotune" | "logs" | "settings"; const { button, div, input, p, span, h1, h2 } = van.tags; @@ -229,6 +230,7 @@ const TabsNav = () => h2({ class: "tabs-title" }, "Yaeger"), button({ class: () => `tab-btn ${activeTab.val === "home" ? "active" : ""}`, onclick: () => (activeTab.val = "home") }, "Home"), button({ class: () => `tab-btn ${activeTab.val === "roast" ? "active" : ""}`, onclick: () => (activeTab.val = "roast") }, "Roast"), + button({ class: () => `tab-btn ${activeTab.val === "autotune" ? "active" : ""}`, onclick: () => (activeTab.val = "autotune") }, "Autotune"), button({ class: () => `tab-btn ${activeTab.val === "logs" ? "active" : ""}`, onclick: () => (activeTab.val = "logs") }, "Logs"), button({ class: () => `tab-btn ${activeTab.val === "settings" ? "active" : ""}`, onclick: () => (activeTab.val = "settings") }, "Settings"), ); @@ -242,6 +244,8 @@ const appLayout = div( switch (activeTab.val) { case "roast": return roastApp(); + case "autotune": + return autotuneApp(); case "logs": return logsApp(); case "settings": diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index 8339d64..ef62df5 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -16,6 +16,7 @@ export type YaegerMessage = { pidIntegral?: number; pidDerivative?: number; pidOutput?: number; + setpoint?: number; pidTarget?: "BT" | "ET" | "simBT"; pidTuneMethod?: "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; pidAutotune?: boolean; diff --git a/miniweb/src/roast.ts b/miniweb/src/roast.ts index deeeee9..20506fb 100644 --- a/miniweb/src/roast.ts +++ b/miniweb/src/roast.ts @@ -236,15 +236,12 @@ function sendCommand(data: any) { function sendPidControlConfig() { const isRoasting = state.val.currentState.status == RoasterStatus.roasting; const shouldEnablePid = pidEnabled.val && isRoasting; - const shouldAutotune = pidAutotune.val && isRoasting; sendCommand({ id: 1, command: "setPidControl", setpoint: setpoint.val, pidEnabled: shouldEnablePid, pidTarget: tempTarget, - pidTuneMethod: tempMethod, - pidAutotune: shouldAutotune, }); } @@ -390,9 +387,7 @@ let tempI = pidIFactor.val; let tempD = pidDFactor.val; let tempTarget = "BT"; -let tempMethod = "ziegler-nichols"; const pidEnabled = van.state(false); -const pidAutotune = van.state(false); const PIDConfig = () => div( @@ -437,21 +432,6 @@ const PIDConfig = () => option({ value: "simBT" }, "Sim BT"), ), p(), - "Method:", - select( - { - value: tempMethod, - onchange: (e: Event) => { - tempMethod = (e.target as HTMLSelectElement).value; - sendPidControlConfig(); - }, - }, - option({ value: "ziegler-nichols" }, "Ziegler-Nichols"), - option({ value: "tyreus-luyben" }, "Tyreus-Luyben"), - option({ value: "pessen-integral" }, "Pessen Integral"), - option({ value: "no-overshoot" }, "No overshoot"), - ), - p(), button( { onclick: () => { @@ -486,16 +466,6 @@ const PIDConfig = () => }), "PID Enabled", ), - p(), - button( - { - onclick: () => { - pidAutotune.val = !pidAutotune.val; - sendPidControlConfig(); - }, - }, - () => (pidAutotune.val ? "Stop autotune" : "Start autotune"), - ), ); // UI creation From 7eeb0b18198985e36a219902dd0f8e770d9c1348 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 16:46:44 +0200 Subject: [PATCH 051/125] Add fan speed control to autotune page --- miniweb/src/autotune.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts index 7f12b84..c314082 100644 --- a/miniweb/src/autotune.ts +++ b/miniweb/src/autotune.ts @@ -10,6 +10,7 @@ type PidMethod = "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-o const target = van.state("BT"); const method = van.state("ziegler-nichols"); const setpoint = van.state(20); +const fanSpeed = van.state(50); const kp = van.state(1.0); const ki = van.state(0.1); const kd = van.state(0.01); @@ -34,6 +35,9 @@ function syncFromMessage() { if (typeof msg.setpoint === "number") { setpoint.val = msg.setpoint; } + if (typeof msg.FanVal === "number") { + fanSpeed.val = msg.FanVal; + } if (typeof msg.pidAutotune === "boolean") { autotuneActive.val = msg.pidAutotune; } @@ -53,6 +57,11 @@ function applyTargetPid() { } function startAutotune() { + sendCommand({ + id: 1, + command: "setFan", + value: fanSpeed.val, + }); sendCommand({ id: 1, command: "setPidControl", @@ -118,6 +127,29 @@ export const autotuneApp = () => }, }), ), + p( + "Fan (%)", + input({ + type: "number", + min: "0", + max: "100", + value: fanSpeed, + oninput: (e: Event) => { + fanSpeed.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), + button( + { + onclick: () => + sendCommand({ + id: 1, + command: "setFan", + value: fanSpeed.val, + }), + }, + "Apply fan speed", + ), p( "Kp", input({ From 22c76105665c3c7937207d797fce8b7a1e24f390 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 16:50:58 +0200 Subject: [PATCH 052/125] Stop autotune panel from overwriting edited setpoints --- miniweb/src/autotune.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts index c314082..c6bbf28 100644 --- a/miniweb/src/autotune.ts +++ b/miniweb/src/autotune.ts @@ -26,18 +26,6 @@ function syncFromMessage() { if (!msg) { return; } - if (msg.pidTarget) { - target.val = msg.pidTarget; - } - if (msg.pidTuneMethod) { - method.val = msg.pidTuneMethod; - } - if (typeof msg.setpoint === "number") { - setpoint.val = msg.setpoint; - } - if (typeof msg.FanVal === "number") { - fanSpeed.val = msg.FanVal; - } if (typeof msg.pidAutotune === "boolean") { autotuneActive.val = msg.pidAutotune; } From d9e28e5a7cb2683de798d49f4858e6ae3e627d0d Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 16:55:45 +0200 Subject: [PATCH 053/125] Show autotune measured temps and live graph --- miniweb/src/autotune.ts | 100 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts index c6bbf28..c07698b 100644 --- a/miniweb/src/autotune.ts +++ b/miniweb/src/autotune.ts @@ -15,6 +15,8 @@ const kp = van.state(1.0); const ki = van.state(0.1); const kd = van.state(0.01); const autotuneActive = van.state(false); +const history = van.state>([]); +const HISTORY_LIMIT = 300; function sendCommand(data: any) { const authToken = getAdminSecret(); @@ -29,6 +31,20 @@ function syncFromMessage() { if (typeof msg.pidAutotune === "boolean") { autotuneActive.val = msg.pidAutotune; } + if ( + typeof msg.ET === "number" && + typeof msg.BT === "number" && + typeof msg.simBT === "number" + ) { + const sample = { + ts: Date.now(), + ET: msg.ET, + BT: msg.BT, + simBT: msg.simBT, + }; + const nextHistory = [...history.val, sample]; + history.val = nextHistory.slice(Math.max(0, nextHistory.length - HISTORY_LIMIT)); + } } van.derive(syncFromMessage); @@ -71,11 +87,95 @@ function stopAutotune() { autotuneActive.val = false; } +function currentTargetTemp() { + const msg = lastMessage.val; + if (!msg) { + return null; + } + if (target.val === "ET") { + return msg.ET ?? null; + } + if (target.val === "simBT") { + return msg.simBT ?? null; + } + return msg.BT ?? null; +} + +const graphCanvas = document.createElement("canvas"); +graphCanvas.width = 700; +graphCanvas.height = 220; + +function drawGraph() { + const ctx = graphCanvas.getContext("2d"); + if (!ctx) { + return; + } + const samples = history.val; + const width = graphCanvas.width; + const height = graphCanvas.height; + + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = "#111827"; + ctx.fillRect(0, 0, width, height); + + if (samples.length < 2) { + ctx.fillStyle = "#9ca3af"; + ctx.font = "14px sans-serif"; + ctx.fillText("Waiting for sensor samples…", 16, 24); + return; + } + + const values = samples.map((s) => (target.val === "ET" ? s.ET : target.val === "simBT" ? s.simBT : s.BT)); + const minV = Math.min(...values, setpoint.val) - 3; + const maxV = Math.max(...values, setpoint.val) + 3; + const range = Math.max(1, maxV - minV); + + const xFor = (idx: number) => (idx / (samples.length - 1)) * (width - 20) + 10; + const yFor = (v: number) => height - 20 - ((v - minV) / range) * (height - 40); + + ctx.strokeStyle = "#374151"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(10, yFor(setpoint.val)); + ctx.lineTo(width - 10, yFor(setpoint.val)); + ctx.stroke(); + + ctx.strokeStyle = "#22d3ee"; + ctx.lineWidth = 2; + ctx.beginPath(); + values.forEach((v, idx) => { + const x = xFor(idx); + const y = yFor(v); + if (idx === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + + ctx.fillStyle = "#e5e7eb"; + ctx.font = "12px sans-serif"; + ctx.fillText(`Target ${target.val} • ${values[values.length - 1].toFixed(1)}°C`, 12, 16); + ctx.fillText(`Setpoint ${setpoint.val.toFixed(1)}°C`, width - 150, 16); +} + +van.derive(drawGraph); + export const autotuneApp = () => div( { class: "section" }, h2("PID Autotune"), p("Use this page to tune PID without starting a roast session."), + p( + "Measured: ET ", + () => lastMessage.val?.ET?.toFixed(1) ?? "N/A", + "°C | BT ", + () => lastMessage.val?.BT?.toFixed(1) ?? "N/A", + "°C | Sim BT ", + () => lastMessage.val?.simBT?.toFixed(1) ?? "N/A", + "°C | Selected ", + () => currentTargetTemp()?.toFixed(1) ?? "N/A", + "°C", + ), + div(graphCanvas), p( "Target", select( From 185122cbc52eca99504feef6c7e1054a22c9330e Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 17:02:42 +0200 Subject: [PATCH 054/125] Fix autotune start race and add firmware autotune logs --- miniweb/src/autotune.ts | 6 +----- src/CommandLoop.cpp | 12 +++++++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts index c07698b..368799f 100644 --- a/miniweb/src/autotune.ts +++ b/miniweb/src/autotune.ts @@ -61,14 +61,10 @@ function applyTargetPid() { } function startAutotune() { - sendCommand({ - id: 1, - command: "setFan", - value: fanSpeed.val, - }); sendCommand({ id: 1, command: "setPidControl", + FanVal: fanSpeed.val, pidEnabled: false, pidTarget: target.val, pidTuneMethod: method.val, diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 99111af..c6e4de5 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -67,6 +67,7 @@ bool enforceMutatingCommandAuth(AsyncWebSocketClient *client, JsonDocument &doc) unsigned long now = millis(); if (now - lastMutatingCommandMs < MUTATING_CMD_MIN_INTERVAL_MS) { + logf("Mutating command rate-limited (delta=%lu ms)\n", now - lastMutatingCommandMs); client->text("{\"error\":\"rate limit exceeded\"}"); return false; } @@ -264,11 +265,14 @@ void startPidAutotune() { pidAutotuneCrossings = 0; pidEnabled = false; preferences.putBool("pidEnabled", false); + logf("PID autotune started (target=%s, method=%s, setpoint=%.2f)\n", pidTargetToString(pidTarget), + pidMethodToString(pidTuneMethod), pidSetpoint); } -void stopPidAutotune() { +void stopPidAutotune(const char *reason) { pidAutotuneActive = false; setHeaterPower(0); + logf("PID autotune stopped (%s)\n", reason == NULL ? "unknown" : reason); } double readPidTargetTemp(PidTargetSensor target, const float *etbt) { @@ -454,7 +458,7 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp if (shouldAutotune) { startPidAutotune(); } else { - stopPidAutotune(); + stopPidAutotune("requested by client"); } } } @@ -659,7 +663,9 @@ void updatePidControl() { const double ku = (4.0 * relayAmplitude) / (M_PI * oscillationAmplitude); const double puSeconds = 2.0 * (pidAutotuneHalfCycleSecondsSum / pidAutotuneHalfCycleCount); applyAutotunedPidGains(ku, puSeconds); - stopPidAutotune(); + logf("PID autotune converged (Ku=%.4f, Pu=%.4f, peakHigh=%.2f, peakLow=%.2f)\n", ku, puSeconds, + pidAutotunePeakHigh, pidAutotunePeakLow); + stopPidAutotune("converged"); resetPidState(); } From 441f17fd39f9bf11dd001370592b8ca61b5b2e74 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 17:09:55 +0200 Subject: [PATCH 055/125] Expose autotune telemetry and progress in UI --- miniweb/src/autotune.ts | 51 +++++++++++++++++++++++++++++++++++++++++ miniweb/src/model.ts | 12 ++++++++++ src/CommandLoop.cpp | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts index 368799f..013861c 100644 --- a/miniweb/src/autotune.ts +++ b/miniweb/src/autotune.ts @@ -17,6 +17,8 @@ const kd = van.state(0.01); const autotuneActive = van.state(false); const history = van.state>([]); const HISTORY_LIMIT = 300; +const autotuneLog = van.state([]); +let lastCrossingsSeen = -1; function sendCommand(data: any) { const authToken = getAdminSecret(); @@ -31,6 +33,19 @@ function syncFromMessage() { if (typeof msg.pidAutotune === "boolean") { autotuneActive.val = msg.pidAutotune; } + if (typeof msg.pidAutotuneCrossings === "number" && msg.pidAutotuneCrossings !== lastCrossingsSeen) { + lastCrossingsSeen = msg.pidAutotuneCrossings; + autotuneLog.val = [ + ...autotuneLog.val.slice(-24), + `Crossing ${msg.pidAutotuneCrossings}/${msg.pidAutotuneTargetCrossings ?? "?"} • Heater ${msg.pidAutotuneHeaterCommand ?? "?"}%`, + ]; + } + if (!msg.pidAutotune && typeof msg.pidAutotuneKu === "number" && typeof msg.pidAutotunePu === "number") { + const doneMessage = `Autotune complete • Ku ${msg.pidAutotuneKu.toFixed(3)} • Pu ${msg.pidAutotunePu.toFixed(2)}s`; + if (autotuneLog.val[autotuneLog.val.length - 1] !== doneMessage) { + autotuneLog.val = [...autotuneLog.val.slice(-24), doneMessage]; + } + } if ( typeof msg.ET === "number" && typeof msg.BT === "number" && @@ -72,6 +87,7 @@ function startAutotune() { pidAutotune: true, }); autotuneActive.val = true; + autotuneLog.val = ["Autotune requested… waiting for crossings"]; } function stopAutotune() { @@ -160,6 +176,37 @@ export const autotuneApp = () => { class: "section" }, h2("PID Autotune"), p("Use this page to tune PID without starting a roast session."), + p( + "Autotune: ", + () => (lastMessage.val?.pidAutotune ? "Running" : "Idle"), + " | Crossings ", + () => `${lastMessage.val?.pidAutotuneCrossings ?? 0}/${lastMessage.val?.pidAutotuneTargetCrossings ?? "?"}`, + " | Heater PWM ", + () => `${lastMessage.val?.pidAutotuneHeaterCommand?.toFixed(0) ?? "N/A"}%`, + " | Elapsed ", + () => `${lastMessage.val?.pidAutotuneElapsedSec?.toFixed(1) ?? "N/A"}s`, + " | ETA ", + () => `${lastMessage.val?.pidAutotuneEtaSec?.toFixed(1) ?? "N/A"}s`, + ), + p( + "Peaks: High ", + () => lastMessage.val?.pidAutotunePeakHigh?.toFixed(2) ?? "N/A", + "°C | Low ", + () => lastMessage.val?.pidAutotunePeakLow?.toFixed(2) ?? "N/A", + "°C | Ku ", + () => lastMessage.val?.pidAutotuneKu?.toFixed(3) ?? "N/A", + " | Pu ", + () => lastMessage.val?.pidAutotunePu?.toFixed(3) ?? "N/A", + "s", + ), + p( + "Active PID for target: Kp ", + () => lastMessage.val?.pidKpActive?.toFixed(3) ?? "N/A", + " | Ki ", + () => lastMessage.val?.pidKiActive?.toFixed(3) ?? "N/A", + " | Kd ", + () => lastMessage.val?.pidKdActive?.toFixed(3) ?? "N/A", + ), p( "Measured: ET ", () => lastMessage.val?.ET?.toFixed(1) ?? "N/A", @@ -270,4 +317,8 @@ export const autotuneApp = () => " ", button({ onclick: stopAutotune, disabled: () => !autotuneActive.val }, "Stop autotune"), p("Autotune status: ", () => (autotuneActive.val ? "Running" : "Idle")), + div( + p("Autotune log:"), + () => autotuneLog.val.map((line) => div("• ", line)), + ), ); diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index ef62df5..419fb27 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -20,6 +20,18 @@ export type YaegerMessage = { pidTarget?: "BT" | "ET" | "simBT"; pidTuneMethod?: "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; pidAutotune?: boolean; + pidAutotuneCrossings?: number; + pidAutotuneTargetCrossings?: number; + pidAutotunePeakHigh?: number; + pidAutotunePeakLow?: number; + pidAutotuneKu?: number; + pidAutotunePu?: number; + pidAutotuneElapsedSec?: number; + pidAutotuneEtaSec?: number; + pidAutotuneHeaterCommand?: number; + pidKpActive?: number; + pidKiActive?: number; + pidKdActive?: number; id: number; } diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index c6e4de5..2f1b6d1 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -45,6 +45,10 @@ unsigned long pidAutotuneLastCrossingMs = 0; double pidAutotuneHalfCycleSecondsSum = 0.0; int pidAutotuneHalfCycleCount = 0; int pidAutotuneCrossings = 0; +unsigned long pidAutotuneStartMs = 0; +double pidAutotuneKu = NAN; +double pidAutotunePu = NAN; +double pidAutotuneHeaterCommand = 0.0; constexpr double PID_AUTOTUNE_RELAY_OUTPUT_HIGH = 60.0; constexpr double PID_AUTOTUNE_RELAY_OUTPUT_LOW = 20.0; constexpr int PID_AUTOTUNE_MIN_CROSSINGS = 8; @@ -263,6 +267,10 @@ void startPidAutotune() { pidAutotuneHalfCycleSecondsSum = 0.0; pidAutotuneHalfCycleCount = 0; pidAutotuneCrossings = 0; + pidAutotuneStartMs = millis(); + pidAutotuneKu = NAN; + pidAutotunePu = NAN; + pidAutotuneHeaterCommand = 0.0; pidEnabled = false; preferences.putBool("pidEnabled", false); logf("PID autotune started (target=%s, method=%s, setpoint=%.2f)\n", pidTargetToString(pidTarget), @@ -272,6 +280,7 @@ void startPidAutotune() { void stopPidAutotune(const char *reason) { pidAutotuneActive = false; setHeaterPower(0); + pidAutotuneHeaterCommand = 0.0; logf("PID autotune stopped (%s)\n", reason == NULL ? "unknown" : reason); } @@ -518,6 +527,22 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp dataObj["pidIntegral"] = pidIntegral; dataObj["pidDerivative"] = pidDerivative; dataObj["pidOutput"] = pidOutput; + dataObj["pidAutotuneCrossings"] = pidAutotuneCrossings; + dataObj["pidAutotuneTargetCrossings"] = PID_AUTOTUNE_MIN_CROSSINGS; + dataObj["pidAutotunePeakHigh"] = pidAutotunePeakHigh; + dataObj["pidAutotunePeakLow"] = pidAutotunePeakLow; + dataObj["pidAutotuneKu"] = pidAutotuneKu; + dataObj["pidAutotunePu"] = pidAutotunePu; + dataObj["pidAutotuneElapsedSec"] = pidAutotuneStartMs > 0 ? (millis() - pidAutotuneStartMs) / 1000.0 : 0.0; + double avgHalfCycle = pidAutotuneHalfCycleCount > 0 ? pidAutotuneHalfCycleSecondsSum / pidAutotuneHalfCycleCount : NAN; + dataObj["pidAutotuneEtaSec"] = + (pidAutotuneActive && !isnan(avgHalfCycle)) + ? std::max(0.0, (PID_AUTOTUNE_MIN_CROSSINGS - pidAutotuneCrossings) * avgHalfCycle) + : NAN; + dataObj["pidAutotuneHeaterCommand"] = pidAutotuneHeaterCommand; + dataObj["pidKpActive"] = getPidGain("pidKp", pidTarget, 1.0); + dataObj["pidKiActive"] = getPidGain("pidKi", pidTarget, 0.1); + dataObj["pidKdActive"] = getPidGain("pidKd", pidTarget, 0.01); } if (command != NULL && strncmp(command, "getData", 7) == 0) { @@ -545,6 +570,22 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp dataObj["pidIntegral"] = pidIntegral; dataObj["pidDerivative"] = pidDerivative; dataObj["pidOutput"] = pidOutput; + dataObj["pidAutotuneCrossings"] = pidAutotuneCrossings; + dataObj["pidAutotuneTargetCrossings"] = PID_AUTOTUNE_MIN_CROSSINGS; + dataObj["pidAutotunePeakHigh"] = pidAutotunePeakHigh; + dataObj["pidAutotunePeakLow"] = pidAutotunePeakLow; + dataObj["pidAutotuneKu"] = pidAutotuneKu; + dataObj["pidAutotunePu"] = pidAutotunePu; + dataObj["pidAutotuneElapsedSec"] = pidAutotuneStartMs > 0 ? (millis() - pidAutotuneStartMs) / 1000.0 : 0.0; + double avgHalfCycle = pidAutotuneHalfCycleCount > 0 ? pidAutotuneHalfCycleSecondsSum / pidAutotuneHalfCycleCount : NAN; + dataObj["pidAutotuneEtaSec"] = + (pidAutotuneActive && !isnan(avgHalfCycle)) + ? std::max(0.0, (PID_AUTOTUNE_MIN_CROSSINGS - pidAutotuneCrossings) * avgHalfCycle) + : NAN; + dataObj["pidAutotuneHeaterCommand"] = pidAutotuneHeaterCommand; + dataObj["pidKpActive"] = getPidGain("pidKp", pidTarget, 1.0); + dataObj["pidKiActive"] = getPidGain("pidKi", pidTarget, 0.1); + dataObj["pidKdActive"] = getPidGain("pidKd", pidTarget, 0.01); } String response; @@ -631,6 +672,7 @@ void updatePidControl() { pidAutotunePeakHigh = currentTemp; } setHeaterPower(lround(PID_AUTOTUNE_RELAY_OUTPUT_HIGH)); + pidAutotuneHeaterCommand = PID_AUTOTUNE_RELAY_OUTPUT_HIGH; if (currentTemp >= pidSetpoint) { pidAutotuneRelayHigh = false; if (pidAutotuneLastCrossingMs != 0) { @@ -645,6 +687,7 @@ void updatePidControl() { pidAutotunePeakLow = currentTemp; } setHeaterPower(lround(PID_AUTOTUNE_RELAY_OUTPUT_LOW)); + pidAutotuneHeaterCommand = PID_AUTOTUNE_RELAY_OUTPUT_LOW; if (currentTemp <= pidSetpoint) { pidAutotuneRelayHigh = true; if (pidAutotuneLastCrossingMs != 0) { @@ -662,6 +705,8 @@ void updatePidControl() { const double relayAmplitude = (PID_AUTOTUNE_RELAY_OUTPUT_HIGH - PID_AUTOTUNE_RELAY_OUTPUT_LOW) / 2.0; const double ku = (4.0 * relayAmplitude) / (M_PI * oscillationAmplitude); const double puSeconds = 2.0 * (pidAutotuneHalfCycleSecondsSum / pidAutotuneHalfCycleCount); + pidAutotuneKu = ku; + pidAutotunePu = puSeconds; applyAutotunedPidGains(ku, puSeconds); logf("PID autotune converged (Ku=%.4f, Pu=%.4f, peakHigh=%.2f, peakLow=%.2f)\n", ku, puSeconds, pidAutotunePeakHigh, pidAutotunePeakLow); From 587bfdb3b6b637a6290481aac42577426ef14898 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 17:19:59 +0200 Subject: [PATCH 056/125] Lower autotune relay minimum PWM to zero --- src/CommandLoop.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 2f1b6d1..099a5af 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -50,7 +50,7 @@ double pidAutotuneKu = NAN; double pidAutotunePu = NAN; double pidAutotuneHeaterCommand = 0.0; constexpr double PID_AUTOTUNE_RELAY_OUTPUT_HIGH = 60.0; -constexpr double PID_AUTOTUNE_RELAY_OUTPUT_LOW = 20.0; +constexpr double PID_AUTOTUNE_RELAY_OUTPUT_LOW = 0.0; constexpr int PID_AUTOTUNE_MIN_CROSSINGS = 8; bool isMutatingCommand(const char *command) { From c61ef367a9f92067b7d762311dc1b209a2f559f8 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 17:47:43 +0200 Subject: [PATCH 057/125] Add configurable autotune PWM bounds and fix UI log rendering --- miniweb/src/autotune.ts | 49 ++++++++++++++++++++++++++++++++++++----- miniweb/src/model.ts | 2 ++ src/CommandLoop.cpp | 47 +++++++++++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts index 013861c..a806e49 100644 --- a/miniweb/src/autotune.ts +++ b/miniweb/src/autotune.ts @@ -2,7 +2,7 @@ import van from "vanjs-core"; import { getAdminSecret } from "./auth"; import { lastMessage, socket } from "./websocket"; -const { button, div, h2, input, option, p, select } = van.tags; +const { button, div, h2, input, option, p, pre, select } = van.tags; type PidTarget = "BT" | "ET" | "simBT"; type PidMethod = "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; @@ -11,6 +11,8 @@ const target = van.state("BT"); const method = van.state("ziegler-nichols"); const setpoint = van.state(20); const fanSpeed = van.state(50); +const minHeaterPwm = van.state(0); +const maxHeaterPwm = van.state(60); const kp = van.state(1.0); const ki = van.state(0.1); const kd = van.state(0.01); @@ -46,6 +48,17 @@ function syncFromMessage() { autotuneLog.val = [...autotuneLog.val.slice(-24), doneMessage]; } } + if (!msg.pidAutotune && typeof msg.pidKpActive === "number" && typeof msg.pidKiActive === "number" && typeof msg.pidKdActive === "number") { + kp.val = msg.pidKpActive; + ki.val = msg.pidKiActive; + kd.val = msg.pidKdActive; + } + if (typeof msg.pidAutotuneMin === "number") { + minHeaterPwm.val = msg.pidAutotuneMin; + } + if (typeof msg.pidAutotuneMax === "number") { + maxHeaterPwm.val = msg.pidAutotuneMax; + } if ( typeof msg.ET === "number" && typeof msg.BT === "number" && @@ -84,6 +97,8 @@ function startAutotune() { pidTarget: target.val, pidTuneMethod: method.val, setpoint: setpoint.val, + pidAutotuneMin: minHeaterPwm.val, + pidAutotuneMax: maxHeaterPwm.val, pidAutotune: true, }); autotuneActive.val = true; @@ -187,6 +202,8 @@ export const autotuneApp = () => () => `${lastMessage.val?.pidAutotuneElapsedSec?.toFixed(1) ?? "N/A"}s`, " | ETA ", () => `${lastMessage.val?.pidAutotuneEtaSec?.toFixed(1) ?? "N/A"}s`, + " | Min/Max PWM ", + () => `${lastMessage.val?.pidAutotuneMin?.toFixed(0) ?? "N/A"}-${lastMessage.val?.pidAutotuneMax?.toFixed(0) ?? "N/A"}%`, ), p( "Peaks: High ", @@ -258,6 +275,30 @@ export const autotuneApp = () => }, }), ), + p( + "Heater Min PWM (%)", + input({ + type: "number", + min: "0", + max: "100", + value: minHeaterPwm, + oninput: (e: Event) => { + minHeaterPwm.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), + p( + "Heater Max PWM (%)", + input({ + type: "number", + min: "0", + max: "100", + value: maxHeaterPwm, + oninput: (e: Event) => { + maxHeaterPwm.val = parseFloat((e.target as HTMLInputElement).value) || 0; + }, + }), + ), p( "Fan (%)", input({ @@ -317,8 +358,6 @@ export const autotuneApp = () => " ", button({ onclick: stopAutotune, disabled: () => !autotuneActive.val }, "Stop autotune"), p("Autotune status: ", () => (autotuneActive.val ? "Running" : "Idle")), - div( - p("Autotune log:"), - () => autotuneLog.val.map((line) => div("• ", line)), - ), + p("Autotune log:"), + pre(() => autotuneLog.val.map((line) => `• ${line}`).join("\n")), ); diff --git a/miniweb/src/model.ts b/miniweb/src/model.ts index 419fb27..b75d4f1 100644 --- a/miniweb/src/model.ts +++ b/miniweb/src/model.ts @@ -29,6 +29,8 @@ export type YaegerMessage = { pidAutotuneElapsedSec?: number; pidAutotuneEtaSec?: number; pidAutotuneHeaterCommand?: number; + pidAutotuneMin?: number; + pidAutotuneMax?: number; pidKpActive?: number; pidKiActive?: number; pidKdActive?: number; diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 099a5af..77fc0bd 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -49,8 +49,8 @@ unsigned long pidAutotuneStartMs = 0; double pidAutotuneKu = NAN; double pidAutotunePu = NAN; double pidAutotuneHeaterCommand = 0.0; -constexpr double PID_AUTOTUNE_RELAY_OUTPUT_HIGH = 60.0; -constexpr double PID_AUTOTUNE_RELAY_OUTPUT_LOW = 0.0; +double pidAutotuneRelayOutputHigh = 60.0; +double pidAutotuneRelayOutputLow = 0.0; constexpr int PID_AUTOTUNE_MIN_CROSSINGS = 8; bool isMutatingCommand(const char *command) { @@ -247,6 +247,14 @@ bool validateCommandSchema(AsyncWebSocketClient *client, JsonDocument &doc, cons client->text("{\"error\":\"invalid schema: pidTuneMethod must be string\"}"); return false; } + if (!doc["pidAutotuneMin"].isNull() && !doc["pidAutotuneMin"].is()) { + client->text("{\"error\":\"invalid schema: pidAutotuneMin must be numeric\"}"); + return false; + } + if (!doc["pidAutotuneMax"].isNull() && !doc["pidAutotuneMax"].is()) { + client->text("{\"error\":\"invalid schema: pidAutotuneMax must be numeric\"}"); + return false; + } } return true; @@ -275,6 +283,7 @@ void startPidAutotune() { preferences.putBool("pidEnabled", false); logf("PID autotune started (target=%s, method=%s, setpoint=%.2f)\n", pidTargetToString(pidTarget), pidMethodToString(pidTuneMethod), pidSetpoint); + logf("PID autotune relay bounds min=%.1f max=%.1f\n", pidAutotuneRelayOutputLow, pidAutotuneRelayOutputHigh); } void stopPidAutotune(const char *reason) { @@ -470,6 +479,19 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp stopPidAutotune("requested by client"); } } + if (!doc["pidAutotuneMin"].isNull()) { + pidAutotuneRelayOutputLow = std::clamp(doc["pidAutotuneMin"].as(), 0.0, 100.0); + preferences.putDouble("pidAutoMin", pidAutotuneRelayOutputLow); + } + if (!doc["pidAutotuneMax"].isNull()) { + pidAutotuneRelayOutputHigh = std::clamp(doc["pidAutotuneMax"].as(), 0.0, 100.0); + preferences.putDouble("pidAutoMax", pidAutotuneRelayOutputHigh); + } + if (pidAutotuneRelayOutputLow > pidAutotuneRelayOutputHigh) { + double temp = pidAutotuneRelayOutputLow; + pidAutotuneRelayOutputLow = pidAutotuneRelayOutputHigh; + pidAutotuneRelayOutputHigh = temp; + } } if (getHeaterPower() > 0 && getFanSpeed() <= 30) { @@ -540,6 +562,8 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp ? std::max(0.0, (PID_AUTOTUNE_MIN_CROSSINGS - pidAutotuneCrossings) * avgHalfCycle) : NAN; dataObj["pidAutotuneHeaterCommand"] = pidAutotuneHeaterCommand; + dataObj["pidAutotuneMin"] = pidAutotuneRelayOutputLow; + dataObj["pidAutotuneMax"] = pidAutotuneRelayOutputHigh; dataObj["pidKpActive"] = getPidGain("pidKp", pidTarget, 1.0); dataObj["pidKiActive"] = getPidGain("pidKi", pidTarget, 0.1); dataObj["pidKdActive"] = getPidGain("pidKd", pidTarget, 0.01); @@ -583,6 +607,8 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp ? std::max(0.0, (PID_AUTOTUNE_MIN_CROSSINGS - pidAutotuneCrossings) * avgHalfCycle) : NAN; dataObj["pidAutotuneHeaterCommand"] = pidAutotuneHeaterCommand; + dataObj["pidAutotuneMin"] = pidAutotuneRelayOutputLow; + dataObj["pidAutotuneMax"] = pidAutotuneRelayOutputHigh; dataObj["pidKpActive"] = getPidGain("pidKp", pidTarget, 1.0); dataObj["pidKiActive"] = getPidGain("pidKi", pidTarget, 0.1); dataObj["pidKdActive"] = getPidGain("pidKd", pidTarget, 0.01); @@ -622,6 +648,13 @@ void setupMainLoop(AsyncWebSocket *ws) { pidEnabled = false; preferences.putBool("pidEnabled", false); pidAutotuneActive = false; + pidAutotuneRelayOutputLow = std::clamp(preferences.getDouble("pidAutoMin", 0.0), 0.0, 100.0); + pidAutotuneRelayOutputHigh = std::clamp(preferences.getDouble("pidAutoMax", 60.0), 0.0, 100.0); + if (pidAutotuneRelayOutputLow > pidAutotuneRelayOutputHigh) { + double temp = pidAutotuneRelayOutputLow; + pidAutotuneRelayOutputLow = pidAutotuneRelayOutputHigh; + pidAutotuneRelayOutputHigh = temp; + } ws->onEvent(onWsEvent); } @@ -671,8 +704,8 @@ void updatePidControl() { if (isnan(pidAutotunePeakHigh) || currentTemp > pidAutotunePeakHigh) { pidAutotunePeakHigh = currentTemp; } - setHeaterPower(lround(PID_AUTOTUNE_RELAY_OUTPUT_HIGH)); - pidAutotuneHeaterCommand = PID_AUTOTUNE_RELAY_OUTPUT_HIGH; + setHeaterPower(lround(pidAutotuneRelayOutputHigh)); + pidAutotuneHeaterCommand = pidAutotuneRelayOutputHigh; if (currentTemp >= pidSetpoint) { pidAutotuneRelayHigh = false; if (pidAutotuneLastCrossingMs != 0) { @@ -686,8 +719,8 @@ void updatePidControl() { if (isnan(pidAutotunePeakLow) || currentTemp < pidAutotunePeakLow) { pidAutotunePeakLow = currentTemp; } - setHeaterPower(lround(PID_AUTOTUNE_RELAY_OUTPUT_LOW)); - pidAutotuneHeaterCommand = PID_AUTOTUNE_RELAY_OUTPUT_LOW; + setHeaterPower(lround(pidAutotuneRelayOutputLow)); + pidAutotuneHeaterCommand = pidAutotuneRelayOutputLow; if (currentTemp <= pidSetpoint) { pidAutotuneRelayHigh = true; if (pidAutotuneLastCrossingMs != 0) { @@ -702,7 +735,7 @@ void updatePidControl() { if (pidAutotuneCrossings >= PID_AUTOTUNE_MIN_CROSSINGS && pidAutotuneHalfCycleCount > 0 && !isnan(pidAutotunePeakHigh) && !isnan(pidAutotunePeakLow) && pidAutotunePeakHigh > pidAutotunePeakLow) { const double oscillationAmplitude = (pidAutotunePeakHigh - pidAutotunePeakLow) / 2.0; - const double relayAmplitude = (PID_AUTOTUNE_RELAY_OUTPUT_HIGH - PID_AUTOTUNE_RELAY_OUTPUT_LOW) / 2.0; + const double relayAmplitude = (pidAutotuneRelayOutputHigh - pidAutotuneRelayOutputLow) / 2.0; const double ku = (4.0 * relayAmplitude) / (M_PI * oscillationAmplitude); const double puSeconds = 2.0 * (pidAutotuneHalfCycleSecondsSum / pidAutotuneHalfCycleCount); pidAutotuneKu = ku; From b33369d1d8e96a743f99383de8e038956d7319fb Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 17:57:15 +0200 Subject: [PATCH 058/125] Apply saved PID target globally when setting preferences --- src/CommandLoop.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index 77fc0bd..ba18812 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -505,6 +505,10 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp client->text("{\"error\":\"invalid pidTarget\"}"); return; } + // Keep the active PID target aligned with explicit preference writes so updates + // are immediately visible/used across the UI and control loop. + pidTarget = preferenceTarget; + preferences.putString("pidTarget", pidTargetToString(pidTarget)); } if (!doc["pidKp"].isNull()) { double pidKp = doc["pidKp"].as(); From 87b7fd99e07c8e9e122473e2c8b1a5d2d9cb926e Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 18:07:38 +0200 Subject: [PATCH 059/125] Mirror saved PID gains to global preference keys --- src/CommandLoop.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/CommandLoop.cpp b/src/CommandLoop.cpp index ba18812..c7f0aa0 100644 --- a/src/CommandLoop.cpp +++ b/src/CommandLoop.cpp @@ -338,6 +338,9 @@ void applyAutotunedPidGains(double ku, double puSeconds) { setPidGain("pidKp", pidTarget, kp); setPidGain("pidKi", pidTarget, ki); setPidGain("pidKd", pidTarget, kd); + preferences.putDouble("pidKp", kp); + preferences.putDouble("pidKi", ki); + preferences.putDouble("pidKd", kd); } } // namespace @@ -513,14 +516,17 @@ void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventTyp if (!doc["pidKp"].isNull()) { double pidKp = doc["pidKp"].as(); setPidGain("pidKp", preferenceTarget, pidKp); + preferences.putDouble("pidKp", pidKp); } if (!doc["pidKi"].isNull()) { double pidKi = doc["pidKi"].as(); setPidGain("pidKi", preferenceTarget, pidKi); + preferences.putDouble("pidKi", pidKi); } if (!doc["pidKd"].isNull()) { double pidKd = doc["pidKd"].as(); setPidGain("pidKd", preferenceTarget, pidKd); + preferences.putDouble("pidKd", pidKd); } if (!doc["cooldownFanSpeed"].isNull()) { long cooldownFanSpeed = doc["cooldownFanSpeed"].as(); From f43ef9406799a95f133df7666f4e38956865a012 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 19:11:02 +0200 Subject: [PATCH 060/125] Tidy miniweb form labels and responsive layout --- miniweb/src/main.ts | 19 +++++++++++++------ miniweb/src/style.css | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/miniweb/src/main.ts b/miniweb/src/main.ts index df6dea4..545df29 100644 --- a/miniweb/src/main.ts +++ b/miniweb/src/main.ts @@ -21,7 +21,7 @@ interface DeviceInfo { type AppTab = "home" | "roast" | "autotune" | "logs" | "settings"; -const { button, div, input, p, span, h1, h2 } = van.tags; +const { button, div, input, p, span, h1, h2, label } = van.tags; // State variables const pidPFactor = van.state(1.0); @@ -100,24 +100,27 @@ const PIDConfig = () => h2("PID Factors"), div( { class: "form-grid" }, - p("P:"), + label({ for: "pid-p" }, "P"), input({ + id: "pid-p", type: "number", value: pidPFactor.val, oninput: (e: Event) => { pidPFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; }, }), - p("I:"), + label({ for: "pid-i" }, "I"), input({ + id: "pid-i", type: "number", value: pidIFactor.val, oninput: (e: Event) => { pidIFactor.val = parseFloat((e.target as HTMLInputElement).value) || 0; }, }), - p("D:"), + label({ for: "pid-d" }, "D"), input({ + id: "pid-d", type: "number", value: pidDFactor.val, oninput: (e: Event) => { @@ -204,16 +207,20 @@ const SettingsPanel = () => h2("Wifi Settings"), div( { class: "form-grid" }, - p("Wifi SSID"), + label({ for: "wifi-ssid" }, "Wi‑Fi SSID"), input({ + id: "wifi-ssid", type: "text", + autocomplete: "off", oninput: (e: Event) => { ssidField.val = (e.target as HTMLInputElement).value; }, }), - p("Wifi Password"), + label({ for: "wifi-password" }, "Wi‑Fi Password"), input({ + id: "wifi-password", type: "password", + autocomplete: "new-password", oninput: (e: Event) => { passField.val = (e.target as HTMLInputElement).value; }, diff --git a/miniweb/src/style.css b/miniweb/src/style.css index 810ca58..070a44b 100644 --- a/miniweb/src/style.css +++ b/miniweb/src/style.css @@ -32,6 +32,7 @@ body { min-height: 100vh; background: radial-gradient(circle at top left, #dbeafe 0%, hsl(var(--background)) 40%); color: hsl(var(--foreground)); + font-size: 15px; } h1 { @@ -98,6 +99,10 @@ h1 { box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); } +.tab-content > *:first-child { + margin-top: 0; +} + .muted { color: #64748b; margin-top: 0; @@ -110,9 +115,10 @@ h1 { gap: 0.75rem; } -.form-grid p { +.form-grid label { margin: 0; font-weight: 500; + color: hsl(var(--secondary-foreground)); } button { @@ -179,6 +185,10 @@ textarea:focus { background: hsl(var(--secondary)); color: hsl(var(--secondary-foreground)); } + +.section p { + margin: 0.35rem 0; +} .section h2 { margin-top: 0; margin-bottom: 1rem; @@ -194,10 +204,23 @@ textarea:focus { position: static; flex-direction: row; flex-wrap: wrap; + gap: 0.5rem; } .tabs-title { width: 100%; + margin-bottom: 0.25rem; + } + + .tab-btn { + flex: 1 1 calc(50% - 0.5rem); + justify-content: center; + min-width: 130px; + } + + .form-grid { + grid-template-columns: 1fr; + gap: 0.45rem; } } From 43939c6f846c29c277373304b48ad6bf441b2405 Mon Sep 17 00:00:00 2001 From: Matthew Burton Date: Sat, 11 Apr 2026 19:53:19 +0200 Subject: [PATCH 061/125] Port miniweb UI from VanJS to Preact --- miniweb/index.html | 2 +- miniweb/package.json | 4 +- miniweb/src/autotune.ts | 363 -- miniweb/src/autotune.tsx | 150 + miniweb/src/logs.ts | 135 - miniweb/src/logs.tsx | 105 + miniweb/src/main.ts | 268 -- miniweb/src/main.tsx | 169 + miniweb/src/profiling.ts | 155 - miniweb/src/profiling.tsx | 153 + miniweb/src/roast.ts | 684 ---- miniweb/src/roast.tsx | 372 ++ miniweb/src/websocket.ts | 73 +- miniweb/tsconfig.json | 6 +- miniweb/vite.config.ts | 3 +- miniweb/yarn.lock | 7728 ++++++++++++++++++++++++------------- 16 files changed, 5990 insertions(+), 4380 deletions(-) delete mode 100644 miniweb/src/autotune.ts create mode 100644 miniweb/src/autotune.tsx delete mode 100644 miniweb/src/logs.ts create mode 100644 miniweb/src/logs.tsx delete mode 100644 miniweb/src/main.ts create mode 100644 miniweb/src/main.tsx delete mode 100644 miniweb/src/profiling.ts create mode 100644 miniweb/src/profiling.tsx delete mode 100644 miniweb/src/roast.ts create mode 100644 miniweb/src/roast.tsx diff --git a/miniweb/index.html b/miniweb/index.html index 461c6ba..01e2808 100644 --- a/miniweb/index.html +++ b/miniweb/index.html @@ -7,6 +7,6 @@
- + diff --git a/miniweb/package.json b/miniweb/package.json index 58b28fa..a8f26a1 100644 --- a/miniweb/package.json +++ b/miniweb/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "devDependencies": { + "@preact/preset-vite": "^2.10.2", "@types/chartjs-plugin-trendline": "^1.0.4", "typescript": "~5.6.2", "vite": "^6.0.3" @@ -17,8 +18,7 @@ "chart.js": "^4.4.7", "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^4.1.0", - "vanjs-core": "^1.5.2", - "vanjs-ext": "^0.6.2", + "preact": "^10.27.2", "vite-plugin-pwa": "^0.21.1" } } diff --git a/miniweb/src/autotune.ts b/miniweb/src/autotune.ts deleted file mode 100644 index a806e49..0000000 --- a/miniweb/src/autotune.ts +++ /dev/null @@ -1,363 +0,0 @@ -import van from "vanjs-core"; -import { getAdminSecret } from "./auth"; -import { lastMessage, socket } from "./websocket"; - -const { button, div, h2, input, option, p, pre, select } = van.tags; - -type PidTarget = "BT" | "ET" | "simBT"; -type PidMethod = "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; - -const target = van.state("BT"); -const method = van.state("ziegler-nichols"); -const setpoint = van.state(20); -const fanSpeed = van.state(50); -const minHeaterPwm = van.state(0); -const maxHeaterPwm = van.state(60); -const kp = van.state(1.0); -const ki = van.state(0.1); -const kd = van.state(0.01); -const autotuneActive = van.state(false); -const history = van.state>([]); -const HISTORY_LIMIT = 300; -const autotuneLog = van.state([]); -let lastCrossingsSeen = -1; - -function sendCommand(data: any) { - const authToken = getAdminSecret(); - socket?.send(JSON.stringify({ ...data, authToken })); -} - -function syncFromMessage() { - const msg = lastMessage.val; - if (!msg) { - return; - } - if (typeof msg.pidAutotune === "boolean") { - autotuneActive.val = msg.pidAutotune; - } - if (typeof msg.pidAutotuneCrossings === "number" && msg.pidAutotuneCrossings !== lastCrossingsSeen) { - lastCrossingsSeen = msg.pidAutotuneCrossings; - autotuneLog.val = [ - ...autotuneLog.val.slice(-24), - `Crossing ${msg.pidAutotuneCrossings}/${msg.pidAutotuneTargetCrossings ?? "?"} • Heater ${msg.pidAutotuneHeaterCommand ?? "?"}%`, - ]; - } - if (!msg.pidAutotune && typeof msg.pidAutotuneKu === "number" && typeof msg.pidAutotunePu === "number") { - const doneMessage = `Autotune complete • Ku ${msg.pidAutotuneKu.toFixed(3)} • Pu ${msg.pidAutotunePu.toFixed(2)}s`; - if (autotuneLog.val[autotuneLog.val.length - 1] !== doneMessage) { - autotuneLog.val = [...autotuneLog.val.slice(-24), doneMessage]; - } - } - if (!msg.pidAutotune && typeof msg.pidKpActive === "number" && typeof msg.pidKiActive === "number" && typeof msg.pidKdActive === "number") { - kp.val = msg.pidKpActive; - ki.val = msg.pidKiActive; - kd.val = msg.pidKdActive; - } - if (typeof msg.pidAutotuneMin === "number") { - minHeaterPwm.val = msg.pidAutotuneMin; - } - if (typeof msg.pidAutotuneMax === "number") { - maxHeaterPwm.val = msg.pidAutotuneMax; - } - if ( - typeof msg.ET === "number" && - typeof msg.BT === "number" && - typeof msg.simBT === "number" - ) { - const sample = { - ts: Date.now(), - ET: msg.ET, - BT: msg.BT, - simBT: msg.simBT, - }; - const nextHistory = [...history.val, sample]; - history.val = nextHistory.slice(Math.max(0, nextHistory.length - HISTORY_LIMIT)); - } -} - -van.derive(syncFromMessage); - -function applyTargetPid() { - sendCommand({ - id: 1, - command: "setPreferences", - pidTarget: target.val, - pidKp: kp.val, - pidKi: ki.val, - pidKd: kd.val, - }); -} - -function startAutotune() { - sendCommand({ - id: 1, - command: "setPidControl", - FanVal: fanSpeed.val, - pidEnabled: false, - pidTarget: target.val, - pidTuneMethod: method.val, - setpoint: setpoint.val, - pidAutotuneMin: minHeaterPwm.val, - pidAutotuneMax: maxHeaterPwm.val, - pidAutotune: true, - }); - autotuneActive.val = true; - autotuneLog.val = ["Autotune requested… waiting for crossings"]; -} - -function stopAutotune() { - sendCommand({ - id: 1, - command: "setPidControl", - pidAutotune: false, - }); - autotuneActive.val = false; -} - -function currentTargetTemp() { - const msg = lastMessage.val; - if (!msg) { - return null; - } - if (target.val === "ET") { - return msg.ET ?? null; - } - if (target.val === "simBT") { - return msg.simBT ?? null; - } - return msg.BT ?? null; -} - -const graphCanvas = document.createElement("canvas"); -graphCanvas.width = 700; -graphCanvas.height = 220; - -function drawGraph() { - const ctx = graphCanvas.getContext("2d"); - if (!ctx) { - return; - } - const samples = history.val; - const width = graphCanvas.width; - const height = graphCanvas.height; - - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = "#111827"; - ctx.fillRect(0, 0, width, height); - - if (samples.length < 2) { - ctx.fillStyle = "#9ca3af"; - ctx.font = "14px sans-serif"; - ctx.fillText("Waiting for sensor samples…", 16, 24); - return; - } - - const values = samples.map((s) => (target.val === "ET" ? s.ET : target.val === "simBT" ? s.simBT : s.BT)); - const minV = Math.min(...values, setpoint.val) - 3; - const maxV = Math.max(...values, setpoint.val) + 3; - const range = Math.max(1, maxV - minV); - - const xFor = (idx: number) => (idx / (samples.length - 1)) * (width - 20) + 10; - const yFor = (v: number) => height - 20 - ((v - minV) / range) * (height - 40); - - ctx.strokeStyle = "#374151"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(10, yFor(setpoint.val)); - ctx.lineTo(width - 10, yFor(setpoint.val)); - ctx.stroke(); - - ctx.strokeStyle = "#22d3ee"; - ctx.lineWidth = 2; - ctx.beginPath(); - values.forEach((v, idx) => { - const x = xFor(idx); - const y = yFor(v); - if (idx === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.stroke(); - - ctx.fillStyle = "#e5e7eb"; - ctx.font = "12px sans-serif"; - ctx.fillText(`Target ${target.val} • ${values[values.length - 1].toFixed(1)}°C`, 12, 16); - ctx.fillText(`Setpoint ${setpoint.val.toFixed(1)}°C`, width - 150, 16); -} - -van.derive(drawGraph); - -export const autotuneApp = () => - div( - { class: "section" }, - h2("PID Autotune"), - p("Use this page to tune PID without starting a roast session."), - p( - "Autotune: ", - () => (lastMessage.val?.pidAutotune ? "Running" : "Idle"), - " | Crossings ", - () => `${lastMessage.val?.pidAutotuneCrossings ?? 0}/${lastMessage.val?.pidAutotuneTargetCrossings ?? "?"}`, - " | Heater PWM ", - () => `${lastMessage.val?.pidAutotuneHeaterCommand?.toFixed(0) ?? "N/A"}%`, - " | Elapsed ", - () => `${lastMessage.val?.pidAutotuneElapsedSec?.toFixed(1) ?? "N/A"}s`, - " | ETA ", - () => `${lastMessage.val?.pidAutotuneEtaSec?.toFixed(1) ?? "N/A"}s`, - " | Min/Max PWM ", - () => `${lastMessage.val?.pidAutotuneMin?.toFixed(0) ?? "N/A"}-${lastMessage.val?.pidAutotuneMax?.toFixed(0) ?? "N/A"}%`, - ), - p( - "Peaks: High ", - () => lastMessage.val?.pidAutotunePeakHigh?.toFixed(2) ?? "N/A", - "°C | Low ", - () => lastMessage.val?.pidAutotunePeakLow?.toFixed(2) ?? "N/A", - "°C | Ku ", - () => lastMessage.val?.pidAutotuneKu?.toFixed(3) ?? "N/A", - " | Pu ", - () => lastMessage.val?.pidAutotunePu?.toFixed(3) ?? "N/A", - "s", - ), - p( - "Active PID for target: Kp ", - () => lastMessage.val?.pidKpActive?.toFixed(3) ?? "N/A", - " | Ki ", - () => lastMessage.val?.pidKiActive?.toFixed(3) ?? "N/A", - " | Kd ", - () => lastMessage.val?.pidKdActive?.toFixed(3) ?? "N/A", - ), - p( - "Measured: ET ", - () => lastMessage.val?.ET?.toFixed(1) ?? "N/A", - "°C | BT ", - () => lastMessage.val?.BT?.toFixed(1) ?? "N/A", - "°C | Sim BT ", - () => lastMessage.val?.simBT?.toFixed(1) ?? "N/A", - "°C | Selected ", - () => currentTargetTemp()?.toFixed(1) ?? "N/A", - "°C", - ), - div(graphCanvas), - p( - "Target", - select( - { - value: target, - onchange: (e: Event) => { - target.val = (e.target as HTMLSelectElement).value as PidTarget; - }, - }, - option({ value: "BT" }, "BT"), - option({ value: "ET" }, "ET"), - option({ value: "simBT" }, "Sim BT"), - ), - ), - p( - "Method", - select( - { - value: method, - onchange: (e: Event) => { - method.val = (e.target as HTMLSelectElement).value as PidMethod; - }, - }, - option({ value: "ziegler-nichols" }, "Ziegler-Nichols"), - option({ value: "tyreus-luyben" }, "Tyreus-Luyben"), - option({ value: "pessen-integral" }, "Pessen Integral"), - option({ value: "no-overshoot" }, "No overshoot"), - ), - ), - p( - "Setpoint (°C)", - input({ - type: "number", - value: setpoint, - oninput: (e: Event) => { - setpoint.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - p( - "Heater Min PWM (%)", - input({ - type: "number", - min: "0", - max: "100", - value: minHeaterPwm, - oninput: (e: Event) => { - minHeaterPwm.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - p( - "Heater Max PWM (%)", - input({ - type: "number", - min: "0", - max: "100", - value: maxHeaterPwm, - oninput: (e: Event) => { - maxHeaterPwm.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - p( - "Fan (%)", - input({ - type: "number", - min: "0", - max: "100", - value: fanSpeed, - oninput: (e: Event) => { - fanSpeed.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - button( - { - onclick: () => - sendCommand({ - id: 1, - command: "setFan", - value: fanSpeed.val, - }), - }, - "Apply fan speed", - ), - p( - "Kp", - input({ - type: "number", - value: kp, - oninput: (e: Event) => { - kp.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - p( - "Ki", - input({ - type: "number", - value: ki, - oninput: (e: Event) => { - ki.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - p( - "Kd", - input({ - type: "number", - value: kd, - oninput: (e: Event) => { - kd.val = parseFloat((e.target as HTMLInputElement).value) || 0; - }, - }), - ), - button({ onclick: applyTargetPid }, "Save PID for target"), - " ", - button({ onclick: startAutotune, disabled: () => autotuneActive.val }, "Start autotune"), - " ", - button({ onclick: stopAutotune, disabled: () => !autotuneActive.val }, "Stop autotune"), - p("Autotune status: ", () => (autotuneActive.val ? "Running" : "Idle")), - p("Autotune log:"), - pre(() => autotuneLog.val.map((line) => `• ${line}`).join("\n")), - ); diff --git a/miniweb/src/autotune.tsx b/miniweb/src/autotune.tsx new file mode 100644 index 0000000..4e946eb --- /dev/null +++ b/miniweb/src/autotune.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { getAdminSecret } from "./auth"; +import { sendWsCommand, useSocketState } from "./websocket"; + +type PidTarget = "BT" | "ET" | "simBT"; +type PidMethod = "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; + +export function AutotuneApp() { + const { lastMessage } = useSocketState(); + const [target, setTarget] = useState("BT"); + const [method, setMethod] = useState("ziegler-nichols"); + const [setpoint, setSetpoint] = useState(20); + const [fanSpeed, setFanSpeed] = useState(50); + const [minHeaterPwm, setMinHeaterPwm] = useState(0); + const [maxHeaterPwm, setMaxHeaterPwm] = useState(60); + const [kp, setKp] = useState(1.0); + const [ki, setKi] = useState(0.1); + const [kd, setKd] = useState(0.01); + const [history, setHistory] = useState>([]); + const [autotuneLog, setAutotuneLog] = useState([]); + const lastCrossing = useRef(-1); + const canvasRef = useRef(null); + + const sendCommand = (data: Record) => { + const authToken = getAdminSecret(); + sendWsCommand({ ...data, authToken }); + }; + + useEffect(() => { + if (!lastMessage) return; + + if (typeof lastMessage.pidAutotuneCrossings === "number" && lastMessage.pidAutotuneCrossings !== lastCrossing.current) { + lastCrossing.current = lastMessage.pidAutotuneCrossings; + setAutotuneLog((prev) => [ + ...prev.slice(-24), + `Crossing ${lastMessage.pidAutotuneCrossings}/${lastMessage.pidAutotuneTargetCrossings ?? "?"} • Heater ${lastMessage.pidAutotuneHeaterCommand ?? "?"}%`, + ]); + } + + if (!lastMessage.pidAutotune && typeof lastMessage.pidKpActive === "number") { + setKp(lastMessage.pidKpActive); + setKi(lastMessage.pidKiActive ?? ki); + setKd(lastMessage.pidKdActive ?? kd); + } + + if (typeof lastMessage.pidAutotuneMin === "number") setMinHeaterPwm(lastMessage.pidAutotuneMin); + if (typeof lastMessage.pidAutotuneMax === "number") setMaxHeaterPwm(lastMessage.pidAutotuneMax); + + if (typeof lastMessage.ET === "number" && typeof lastMessage.BT === "number" && typeof lastMessage.simBT === "number") { + setHistory((prev) => [...prev, { ET: lastMessage.ET, BT: lastMessage.BT, simBT: lastMessage.simBT }].slice(-300)); + } + }, [kd, ki, lastMessage]); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + + const width = canvas.width; + const height = canvas.height; + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = "#111827"; + ctx.fillRect(0, 0, width, height); + + if (history.length < 2) { + ctx.fillStyle = "#9ca3af"; + ctx.font = "14px sans-serif"; + ctx.fillText("Waiting for sensor samples…", 16, 24); + return; + } + + const values = history.map((s) => (target === "ET" ? s.ET : target === "simBT" ? s.simBT : s.BT)); + const minV = Math.min(...values, setpoint) - 3; + const maxV = Math.max(...values, setpoint) + 3; + const range = Math.max(1, maxV - minV); + const xFor = (i: number) => (i / (history.length - 1)) * (width - 20) + 10; + const yFor = (v: number) => height - 20 - ((v - minV) / range) * (height - 40); + + ctx.strokeStyle = "#374151"; + ctx.beginPath(); + ctx.moveTo(10, yFor(setpoint)); + ctx.lineTo(width - 10, yFor(setpoint)); + ctx.stroke(); + + ctx.strokeStyle = "#22d3ee"; + ctx.lineWidth = 2; + ctx.beginPath(); + values.forEach((v, i) => { + const x = xFor(i); + const y = yFor(v); + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.stroke(); + }, [history, setpoint, target]); + + return ( +
+

PID Autotune

+

+ Autotune: {lastMessage?.pidAutotune ? "Running" : "Idle"} | Crossings {lastMessage?.pidAutotuneCrossings ?? 0}/ + {lastMessage?.pidAutotuneTargetCrossings ?? "?"} +

+ +

+ Target + +

+

+ Method + +

+

Setpoint setSetpoint(Number((e.target as HTMLInputElement).value) || 0)} />

+

Fan setFanSpeed(Number((e.target as HTMLInputElement).value) || 0)} />

+

Min PWM setMinHeaterPwm(Number((e.target as HTMLInputElement).value) || 0)} />

+

Max PWM setMaxHeaterPwm(Number((e.target as HTMLInputElement).value) || 0)} />

+

+ Kp setKp(Number((e.target as HTMLInputElement).value) || 0)} /> + Ki setKi(Number((e.target as HTMLInputElement).value) || 0)} /> + Kd setKd(Number((e.target as HTMLInputElement).value) || 0)} /> +

+ + + +
{autotuneLog.slice(-25).join("\n")}
+
+ ); +} diff --git a/miniweb/src/logs.ts b/miniweb/src/logs.ts deleted file mode 100644 index 9dc08f3..0000000 --- a/miniweb/src/logs.ts +++ /dev/null @@ -1,135 +0,0 @@ -import van from "vanjs-core"; -import { getBasicAuthHeaderValue } from "./auth"; - -const { div, h1, button, p, textarea, input } = van.tags; - -const logText = van.state(""); -const logError = van.state(""); -const csrfToken = van.state(""); -let refreshTimerId: number | null = null; - -async function fetchCsrfToken() { - const response = await fetch(`http://${location.host}/api/info`); - if (!response.ok) { - throw new Error(`Failed to load CSRF token: ${response.status}`); - } - const info = (await response.json()) as { csrfToken?: string }; - csrfToken.val = info.csrfToken || ""; -} - -async function refreshLogs() { - try { - logError.val = ""; - const response = await fetch(`http://${location.host}/api/logs`); - if (!response.ok) { - throw new Error(`Failed to fetch logs: ${response.status}`); - } - logText.val = await response.text(); - } catch (error) { - logError.val = error instanceof Error ? error.message : "Unknown log error"; - } -} - -async function clearLogs() { - try { - if (!csrfToken.val) { - await fetchCsrfToken(); - } - const response = await fetch(`http://${location.host}/api/logs`, { - method: "DELETE", - headers: { - Authorization: getBasicAuthHeaderValue(), - "X-Yaeger-CSRF": csrfToken.val, - }, - }); - if (!response.ok) { - throw new Error(`Clear failed: ${response.status}`); - } - await refreshLogs(); - } catch (error) { - logError.val = error instanceof Error ? error.message : "Unknown clear error"; - } -} - -function downloadLogs() { - const blob = new Blob([logText.val], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `yaeger-logs-${new Date().toISOString()}.txt`; - a.click(); - URL.revokeObjectURL(url); -} - -async function uploadLogFile(file: File) { - try { - if (!csrfToken.val) { - await fetchCsrfToken(); - } - const body = await file.text(); - const response = await fetch(`http://${location.host}/api/logs/upload`, { - method: "POST", - headers: { - Authorization: getBasicAuthHeaderValue(), - "X-Yaeger-CSRF": csrfToken.val, - "Content-Type": "text/plain", - }, - body, - }); - if (!response.ok) { - throw new Error(`Upload failed: ${response.status}`); - } - await refreshLogs(); - } catch (error) { - logError.val = error instanceof Error ? error.message : "Unknown upload error"; - } -} - -export const logsApp = () => { - void fetchCsrfToken(); - void refreshLogs(); - - if (refreshTimerId != null) { - window.clearInterval(refreshTimerId); - } - refreshTimerId = window.setInterval(() => { - void refreshLogs(); - }, 2000); - - return div( - { class: "start-page" }, - h1("Yaeger Logs"), - p("Live device logs (auto-refresh every 2s)."), - () => - logError.val - ? p({ style: "color: #b91c1c;" }, "Log error: ", logError.val) - : null, - div( - { class: "section" }, - button({ onclick: () => void refreshLogs() }, "Refresh Logs"), - " ", - button({ onclick: downloadLogs }, "Download Logs"), - " ", - button({ onclick: () => void clearLogs() }, "Clear Device Logs"), - ), - div( - { class: "section" }, - p("Upload a log file into device log history for troubleshooting context."), - input({ - type: "file", - accept: ".txt,.log,application/json,text/plain", - onchange: (e: Event) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (file) { - void uploadLogFile(file); - } - }, - }), - ), - textarea({ - readonly: true, - style: "width: 100%; min-height: 320px; font-family: monospace;", - value: () => logText.val, - }), - ); -}; diff --git a/miniweb/src/logs.tsx b/miniweb/src/logs.tsx new file mode 100644 index 0000000..b19001f --- /dev/null +++ b/miniweb/src/logs.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "preact/hooks"; +import { getBasicAuthHeaderValue } from "./auth"; + +export function LogsApp() { + const [logText, setLogText] = useState(""); + const [logError, setLogError] = useState(""); + const [csrfToken, setCsrfToken] = useState(""); + + const fetchCsrfToken = async () => { + const response = await fetch(`http://${location.host}/api/info`); + if (!response.ok) throw new Error(`Failed to load CSRF token: ${response.status}`); + const info = (await response.json()) as { csrfToken?: string }; + setCsrfToken(info.csrfToken || ""); + return info.csrfToken || ""; + }; + + const refreshLogs = async () => { + try { + setLogError(""); + const response = await fetch(`http://${location.host}/api/logs`); + if (!response.ok) throw new Error(`Failed to fetch logs: ${response.status}`); + setLogText(await response.text()); + } catch (error) { + setLogError(error instanceof Error ? error.message : "Unknown log error"); + } + }; + + useEffect(() => { + void fetchCsrfToken(); + void refreshLogs(); + const timer = window.setInterval(() => void refreshLogs(), 2000); + return () => window.clearInterval(timer); + }, []); + + const clearLogs = async () => { + try { + const token = csrfToken || (await fetchCsrfToken()); + const response = await fetch(`http://${location.host}/api/logs`, { + method: "DELETE", + headers: { Authorization: getBasicAuthHeaderValue(), "X-Yaeger-CSRF": token }, + }); + if (!response.ok) throw new Error(`Clear failed: ${response.status}`); + await refreshLogs(); + } catch (error) { + setLogError(error instanceof Error ? error.message : "Unknown clear error"); + } + }; + + const uploadLogFile = async (file: File) => { + try { + const token = csrfToken || (await fetchCsrfToken()); + const body = await file.text(); + const response = await fetch(`http://${location.host}/api/logs/upload`, { + method: "POST", + headers: { + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": token, + "Content-Type": "text/plain", + }, + body, + }); + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + await refreshLogs(); + } catch (error) { + setLogError(error instanceof Error ? error.message : "Unknown upload error"); + } + }; + + return ( +
+

Yaeger Logs

+

Live device logs (auto-refresh every 2s).

+ {logError ?

Log error: {logError}

: null} +
+ + + +
+
+

Upload a log file into device log history for troubleshooting context.

+ { + const file = e.currentTarget.files?.[0]; + if (file) void uploadLogFile(file); + }} + /> +
+