From ecfdf7fece3562f96592212093fae9f2b6c49340 Mon Sep 17 00:00:00 2001 From: shodan Date: Sun, 11 Oct 2026 15:00:30 +0200 Subject: [PATCH 01/12] refactor(http): build the HTTP layer on WebServer middleware and one route table - CORS comes from the core CorsMiddleware instead of headers added by every send call. - Token and WiFi-configured checks are route middleware, so the handler wrappers are gone. - One kRoutes table lists every route with its access rule. 404 and 405 answers come from it, which removes the hand-kept path lists. - Every JSON response is built with ArduinoJson through sendJson and sendError. - Handlers all take WebServer& and share one header. - ServoMove::read parses and checks the servo move for /test/servo and /setup/servo, replacing the two copies of the digit and float checks. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- docs/api.md | 2 +- docs/hardware/testing.md | 2 +- src/http/anim_handlers.cpp | 38 +-- src/http/anim_handlers.h | 6 - src/http/handlers.h | 32 +++ src/http/health_handlers.cpp | 101 +++---- src/http/health_handlers.h | 6 - src/http/http_server.cpp | 54 ---- src/http/index_page.cpp | 9 - src/http/index_page.h | 5 - src/http/json.cpp | 107 -------- src/http/json.h | 21 -- src/http/middleware.cpp | 27 ++ src/http/middleware.h | 9 + src/http/play_handlers.cpp | 27 +- src/http/play_handlers.h | 8 - src/http/request.cpp | 60 +++++ src/http/request.h | 13 + src/http/response.cpp | 15 ++ src/http/response.h | 7 + src/http/routes.cpp | 190 +++++++------ src/http/routes.h | 4 +- src/http/server.cpp | 66 +++++ src/http/{http_server.h => server.h} | 0 src/http/server_context.cpp | 29 -- src/http/server_context.h | 7 - src/http/settings_handlers.cpp | 205 +++----------- src/http/settings_handlers.h | 10 - src/http/setup_handlers.cpp | 390 ++++++--------------------- src/http/setup_handlers.h | 7 - src/http/test_handlers.cpp | 209 +++----------- src/http/test_handlers.h | 7 - src/main.cpp | 2 +- 34 files changed, 548 insertions(+), 1129 deletions(-) delete mode 100644 src/http/anim_handlers.h create mode 100644 src/http/handlers.h delete mode 100644 src/http/health_handlers.h delete mode 100644 src/http/http_server.cpp delete mode 100644 src/http/index_page.cpp delete mode 100644 src/http/index_page.h delete mode 100644 src/http/json.cpp delete mode 100644 src/http/json.h create mode 100644 src/http/middleware.cpp create mode 100644 src/http/middleware.h delete mode 100644 src/http/play_handlers.h create mode 100644 src/http/request.cpp create mode 100644 src/http/request.h create mode 100644 src/http/response.cpp create mode 100644 src/http/response.h create mode 100644 src/http/server.cpp rename src/http/{http_server.h => server.h} (100%) delete mode 100644 src/http/server_context.cpp delete mode 100644 src/http/server_context.h delete mode 100644 src/http/settings_handlers.h delete mode 100644 src/http/setup_handlers.h delete mode 100644 src/http/test_handlers.h diff --git a/AGENTS.md b/AGENTS.md index ae145fa8..89e0affe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ When adding, removing, or changing method, path, or query params of any HTTP rou 1. **HTML index** — [`ui/index.html`](ui/index.html): endpoint table and param lists for `/anim`, `/settings`, `/test/servo` 2. **API reference** — [`docs/api.md`](docs/api.md) 3. **Summaries** — if the endpoint list, params, or boot URLs changed: [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md) -4. **405 paths** — if needed: `isHttpTestPath()` / `handleNotFound` in `src/http/` +4. **Route table** — add the route to `kRoutes` in [`src/http/routes.cpp`](src/http/routes.cpp) with its access rule. 404 and 405 answers come from that table HTML index: one-line route descriptions only (except supported-parameter lists). Param values must match `docs/api.md` exactly. diff --git a/docs/api.md b/docs/api.md index 3ffc8f7e..f12286c7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # HTTP API -JSON HTTP server on the ESP32-C3. Source: [`src/http/http_server.cpp`](../src/http/http_server.cpp) and handlers under [`src/http/`](../src/http/), test routes in [`src/http/test_handlers.cpp`](../src/http/test_handlers.cpp). +JSON HTTP server on the ESP32-C3. Source: [`src/http/`](../src/http/). Every route and its access rule is listed in [`src/http/routes.cpp`](../src/http/routes.cpp). Listens on **port 80** after STA Wi-Fi connects, or during setup AP mode at `http://192.168.4.1/`. Base URL is the board IP (OLED / serial) or `http://tiny-engineer.local` (mDNS, 2.4 GHz only). diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 256a5bc0..29f8a988 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -7,7 +7,7 @@ Firmware is [`src/main.cpp`](../../src/main.cpp). Boot **inits** hardware and st - [`src/network/wifi_connect.cpp`](../../src/network/wifi_connect.cpp) - [`src/audio/audio.cpp`](../../src/audio/audio.cpp) - [`src/hardware/pca9685_servos.cpp`](../../src/hardware/pca9685_servos.cpp) -- [`src/http/http_server.cpp`](../../src/http/http_server.cpp), [`src/http/test_handlers.cpp`](../../src/http/test_handlers.cpp) +- [`src/http/routes.cpp`](../../src/http/routes.cpp), [`src/http/test_handlers.cpp`](../../src/http/test_handlers.cpp) Constants: [`include/pins.h`](../../include/pins.h). Wi-Fi credentials are saved in NVS and configured only in setup AP mode (first boot, or after factory reset + power-cycle). Servo min/max, RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are also setup-AP-only; factory reset keeps them. diff --git a/src/http/anim_handlers.cpp b/src/http/anim_handlers.cpp index 0e456999..ab7447ed 100644 --- a/src/http/anim_handlers.cpp +++ b/src/http/anim_handlers.cpp @@ -1,53 +1,37 @@ -#include "http/anim_handlers.h" - #include -#include #include "animation.h" -#include "http/json.h" +#include "http/handlers.h" +#include "http/response.h" namespace { -void sendAnimationJson(WebServer& server) { - char body[64]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"animation\":\"%s\"}", - animationName(getAnimation()) - ); - - httpSendJson(server, 200, body); +void sendAnimation(WebServer& server) { + JsonDocument body; + body["ok"] = true; + body["animation"] = animationName(getAnimation()); + sendJson(server, 200, body); } } // namespace void handleAnimGet(WebServer& server) { - sendAnimationJson(server); + sendAnimation(server); } void handleAnimPost(WebServer& server) { if (!server.hasArg("name")) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"missing name\"}" - ); + sendError(server, 400, "missing name"); return; } AnimationId id; if (!parseAnimationName(server.arg("name").c_str(), id)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"unknown animation\"}" - ); + sendError(server, 400, "unknown animation"); return; } setAnimation(id); - sendAnimationJson(server); + sendAnimation(server); } diff --git a/src/http/anim_handlers.h b/src/http/anim_handlers.h deleted file mode 100644 index 842fcab9..00000000 --- a/src/http/anim_handlers.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include - -void handleAnimGet(WebServer& server); -void handleAnimPost(WebServer& server); diff --git a/src/http/handlers.h b/src/http/handlers.h new file mode 100644 index 00000000..a41ad68a --- /dev/null +++ b/src/http/handlers.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +void handleIndex(WebServer& server); + +void handleAuth(WebServer& server); +void handleHealth(WebServer& server); + +void handleAnimGet(WebServer& server); +void handleAnimPost(WebServer& server); + +// /play streams its body: handlePlayBody runs for each slice, handlePlayDone once it is consumed. +void handlePlayBody(WebServer& server); +void handlePlayDone(WebServer& server); + +void handleSettingsGet(WebServer& server); +void handleSettingsPost(WebServer& server); +void handleSettingsReset(WebServer& server); +void handleSettingsWifiPut(WebServer& server); + +void handleAudioTest(WebServer& server); +void handleBellTest(WebServer& server); +void handleScreenTest(WebServer& server); +void handleMovementTest(WebServer& server); +void handleLedTest(WebServer& server); +void handleServoTest(WebServer& server); + +void handleSetupServo(WebServer& server); +void handleSetupLed(WebServer& server); +void handleSetupAudio(WebServer& server); +void handleSetupOled(WebServer& server); diff --git a/src/http/health_handlers.cpp b/src/http/health_handlers.cpp index a65bba4d..53bae00e 100644 --- a/src/http/health_handlers.cpp +++ b/src/http/health_handlers.cpp @@ -1,87 +1,52 @@ -#include "http/health_handlers.h" - #include #include -#include #include "display/oled.h" #include "firmware_version.h" #include "hardware/chip_temp.h" -#include "http/json.h" -#include "http/server_context.h" +#include "http/handlers.h" +#include "http/response.h" #include "network/wifi_connect.h" #include "settings/settings.h" -void handleAuth() { - WebServer& server = httpServer(); - char body[128]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"required\":%s,\"wifi_configured\":%s,\"provisioning\":%s}", - settingsAccessTokenSet() ? "true" : "false", - settingsWifiConfigured() ? "true" : "false", - wifiProvisioningMode() ? "true" : "false" - ); - - httpSendJson(server, 200, body); +void handleAuth(WebServer& server) { + JsonDocument body; + body["ok"] = true; + body["required"] = settingsAccessTokenSet(); + body["wifi_configured"] = settingsWifiConfigured(); + body["provisioning"] = wifiProvisioningMode(); + sendJson(server, 200, body); } void handleHealth(WebServer& server) { - const bool wifiOk = - WiFi.status() == WL_CONNECTED; + const bool connected = WiFi.status() == WL_CONNECTED; + const bool provisioning = wifiProvisioningMode(); + JsonDocument body; - refreshMdnsHostname(); + body["ok"] = true; + body["version"] = FW_VERSION; + body["uptime_ms"] = millis(); + body["free_heap"] = ESP.getFreeHeap(); + body["heap_size"] = ESP.getHeapSize(); float tempC = 0.0f; - const bool tempOk = chipTempCelsius(&tempC); - char tempFrag[24]; - if (tempOk) { - snprintf(tempFrag, sizeof(tempFrag), "%.1f", tempC); + + if (chipTempCelsius(&tempC)) { + body["cpu_temp_c"] = serialized(String(tempC, 1)); } else { - snprintf(tempFrag, sizeof(tempFrag), "null"); + body["cpu_temp_c"] = nullptr; } - char body[768]; - - snprintf( - body, - sizeof(body), - "{" - "\"ok\":true," - "\"version\":\"%s\"," - "\"uptime_ms\":%lu," - "\"free_heap\":%u," - "\"heap_size\":%u," - "\"cpu_temp_c\":%s," - "\"wifi\":{" - "\"connected\":%s," - "\"ip\":\"%s\"," - "\"rssi\":%d," - "\"hostname\":\"%s\"" - "}," - "\"wifi_configured\":%s," - "\"provisioning\":%s," - "\"setup_ap_ssid\":\"%s\"," - "\"setup_ap_ip\":\"%s\"," - "\"oled\":%s" - "}", - FW_VERSION, - (unsigned long)millis(), - (unsigned)ESP.getFreeHeap(), - (unsigned)ESP.getHeapSize(), - tempFrag, - wifiOk ? "true" : "false", - wifiOk ? wifiIpText() : "", - wifiOk ? (int)WiFi.RSSI() : 0, - httpMdnsHostname(), - settingsWifiConfigured() ? "true" : "false", - wifiProvisioningMode() ? "true" : "false", - wifiProvisioningMode() ? wifiApSsid() : "", - wifiProvisioningMode() ? wifiApIpText() : "", - oledAvailable ? "true" : "false" - ); - - httpSendJson(server, 200, body); + JsonObject wifi = body["wifi"].to(); + wifi["connected"] = connected; + wifi["ip"] = connected ? wifiIpText() : ""; + wifi["rssi"] = connected ? WiFi.RSSI() : 0; + wifi["hostname"] = String(settingsHostname()) + ".local"; + + body["wifi_configured"] = settingsWifiConfigured(); + body["provisioning"] = provisioning; + body["setup_ap_ssid"] = provisioning ? wifiApSsid() : ""; + body["setup_ap_ip"] = provisioning ? wifiApIpText() : ""; + body["oled"] = oledAvailable; + sendJson(server, 200, body); } diff --git a/src/http/health_handlers.h b/src/http/health_handlers.h deleted file mode 100644 index 1ec46fe1..00000000 --- a/src/http/health_handlers.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include - -void handleAuth(); -void handleHealth(WebServer& server); diff --git a/src/http/http_server.cpp b/src/http/http_server.cpp deleted file mode 100644 index 8abbde43..00000000 --- a/src/http/http_server.cpp +++ /dev/null @@ -1,54 +0,0 @@ -#include "http/http_server.h" - -#include - -#include "http/routes.h" -#include "http/server_context.h" -#include "network/wifi_connect.h" -#include "serial_log.h" - -void startHttpServer() { - if (!wifiConnected() && !wifiProvisioningMode()) { - serialLogPrintln("HTTP: skipped (no wifi)"); - return; - } - - WebServer& server = httpServer(); - - refreshMdnsHostname(); - - static const char* kCollectHeaders[] = {"Authorization"}; - server.collectHeaders(kCollectHeaders, 1); - - registerHttpRoutes(); - server.begin(); - - if (wifiProvisioningMode()) { - serialLogPrint("HTTP setup: http://"); - serialLogPrint(wifiApIpText()); - serialLogPrintln("/config"); - return; - } - - serialLogPrint("HTTP: http://"); - serialLogPrint(wifiIpText()); - serialLogPrintln("/"); - serialLogPrint("HTTP: http://"); - serialLogPrint(httpMdnsHostname()); - serialLogPrintln("/"); - serialLogPrint("HTTP: http://"); - serialLogPrint(wifiIpText()); - serialLogPrintln("/health"); - serialLogPrint("HTTP: http://"); - serialLogPrint(httpMdnsHostname()); - serialLogPrintln("/health"); -} - -void pollHttpServer() { - if (!wifiConnected() && !wifiProvisioningMode()) { - delay(10); - return; - } - - httpServer().handleClient(); -} diff --git a/src/http/index_page.cpp b/src/http/index_page.cpp deleted file mode 100644 index 27d90ad6..00000000 --- a/src/http/index_page.cpp +++ /dev/null @@ -1,9 +0,0 @@ -#include "http/index_page.h" - -#include "web_ui.h" - -void sendIndexPage(WebServer& server) { - server.sendHeader("Content-Encoding", "gzip"); - server.sendHeader("Cache-Control", "no-cache"); - server.send_P(200, "text/html; charset=utf-8", reinterpret_cast(kWebUiGzip), sizeof(kWebUiGzip)); -} diff --git a/src/http/index_page.h b/src/http/index_page.h deleted file mode 100644 index 2a0bdf56..00000000 --- a/src/http/index_page.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#include - -void sendIndexPage(WebServer& server); diff --git a/src/http/json.cpp b/src/http/json.cpp deleted file mode 100644 index 059ab02d..00000000 --- a/src/http/json.cpp +++ /dev/null @@ -1,107 +0,0 @@ -#include "http/json.h" - -#include - -#include "settings/settings.h" - -namespace { - -void sendCorsHeaders(WebServer& server) { - server.sendHeader("Access-Control-Allow-Origin", "*"); - server.sendHeader( - "Access-Control-Allow-Headers", - "Authorization, Content-Type" - ); - server.sendHeader( - "Access-Control-Allow-Methods", - "GET, POST, PUT, OPTIONS" - ); -} - -void sendWithCors(WebServer& server, int code, const char* type, const char* body) { - sendCorsHeaders(server); - server.send(code, type, body); -} - -bool bearerTokenMatches(const String& header, const char* expected) { - constexpr const char* kPrefix = "Bearer "; - constexpr size_t kPrefixLen = 7; - - if (header.length() < kPrefixLen) { - return false; - } - - for (size_t i = 0; i < kPrefixLen; i++) { - if (header[i] != kPrefix[i]) { - return false; - } - } - - const char* presented = header.c_str() + kPrefixLen; - return strcmp(presented, expected) == 0; -} - -} // namespace - -void httpSendJson(WebServer& server, int code, const char* body) { - sendWithCors(server, code, "application/json", body); -} - -void httpSendHtml(WebServer& server, int code, const char* body) { - sendCorsHeaders(server); - // send() copies into Arduino String and fails on the large panel. - server.send_P(code, "text/html; charset=utf-8", body); -} - -void httpSendCorsPreflight(WebServer& server) { - sendCorsHeaders(server); - server.send(204); -} - -bool httpApiAuthorized(WebServer& server) { - return !settingsAccessTokenSet() || - (server.hasHeader("Authorization") && - bearerTokenMatches(server.header("Authorization"), settingsAccessToken())); -} - -bool httpRequireApiAuth(WebServer& server) { - if (!httpApiAuthorized(server)) { - httpSendJson( - server, - 401, - "{\"ok\":false,\"error\":\"unauthorized\"}" - ); - return false; - } - - return true; -} - -bool httpRequireWifiConfigured(WebServer& server) { - if (settingsWifiConfigured()) { - return true; - } - - httpSendJson( - server, - 503, - "{\"ok\":false,\"error\":\"wifi not configured\"}" - ); - return false; -} - -void httpWithApiAuth(WebServer& server, void (*handler)(WebServer&)) { - if (!httpRequireApiAuth(server)) { - return; - } - - handler(server); -} - -void httpWithWifiAndApiAuth(WebServer& server, void (*handler)(WebServer&)) { - if (!httpRequireWifiConfigured(server)) { - return; - } - - httpWithApiAuth(server, handler); -} diff --git a/src/http/json.h b/src/http/json.h deleted file mode 100644 index faec41ee..00000000 --- a/src/http/json.h +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include - -void httpSendJson(WebServer& server, int code, const char* body); -void httpSendHtml(WebServer& server, int code, const char* body); -void httpSendCorsPreflight(WebServer& server); - -bool httpApiAuthorized(WebServer& server); - -// Returns false and sends 401 when access token is configured and missing/wrong. -bool httpRequireApiAuth(WebServer& server); - -// Returns false and sends 503 when WiFi credentials are not saved yet. -bool httpRequireWifiConfigured(WebServer& server); - -// WiFi configured + auth, then call handler. -void httpWithWifiAndApiAuth(WebServer& server, void (*handler)(WebServer&)); - -// Auth, then call handler. No-op if auth fails. -void httpWithApiAuth(WebServer& server, void (*handler)(WebServer&)); diff --git a/src/http/middleware.cpp b/src/http/middleware.cpp new file mode 100644 index 00000000..a0d98799 --- /dev/null +++ b/src/http/middleware.cpp @@ -0,0 +1,27 @@ +#include "http/middleware.h" + +#include "http/response.h" +#include "settings/settings.h" + +bool httpApiAuthorized(WebServer& server) { + return !settingsAccessTokenSet() || + server.header("Authorization") == String("Bearer ") + settingsAccessToken(); +} + +bool requireApiAuth(WebServer& server, Middleware::Callback next) { + if (!httpApiAuthorized(server)) { + sendError(server, 401, "unauthorized"); + return true; + } + + return next(); +} + +bool requireWifiConfigured(WebServer& server, Middleware::Callback next) { + if (!settingsWifiConfigured()) { + sendError(server, 503, "wifi not configured"); + return true; + } + + return next(); +} diff --git a/src/http/middleware.h b/src/http/middleware.h new file mode 100644 index 00000000..606ec2ec --- /dev/null +++ b/src/http/middleware.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +bool httpApiAuthorized(WebServer& server); + +// Route middleware: each answers with a JSON error and stops the chain when its check fails. +bool requireApiAuth(WebServer& server, Middleware::Callback next); +bool requireWifiConfigured(WebServer& server, Middleware::Callback next); diff --git a/src/http/play_handlers.cpp b/src/http/play_handlers.cpp index 1da8bab3..655f6ad7 100644 --- a/src/http/play_handlers.cpp +++ b/src/http/play_handlers.cpp @@ -1,15 +1,14 @@ -#include "http/play_handlers.h" - #include -#include #include #include "animation.h" #include "audio/audio.h" #include "audio/audio_motion.h" #include "audio/wav_parser.h" -#include "http/json.h" +#include "http/handlers.h" +#include "http/middleware.h" +#include "http/response.h" #include "pins.h" #include "robot_tick.h" #include "settings/settings.h" @@ -27,6 +26,7 @@ class PlaySession : public WavStreamParser::Sink { void begin(WebServer& server) { parser_.reset(); rejectCode_ = 0; + rejectMessage_ = nullptr; started_ = false; if (!settingsWifiConfigured()) { @@ -80,18 +80,19 @@ class PlaySession : public WavStreamParser::Sink { void respond(WebServer& server) { if (rejectCode_ != 0) { - httpSendJson(server, rejectCode_, body_); + sendError(server, rejectCode_, rejectMessage_); return; } if (parser_->failed()) { - formatError(parser_->error()); - httpSendJson(server, 415, body_); + sendError(server, 415, parser_->error()); return; } - snprintf(body_, sizeof(body_), "{\"ok\":true,\"played_ms\":%lu}", static_cast(playedMs())); - httpSendJson(server, 200, body_); + JsonDocument body; + body["ok"] = true; + body["played_ms"] = playedMs(); + sendJson(server, 200, body); } void onPcm(const int16_t* samples, size_t count) override { @@ -122,15 +123,11 @@ class PlaySession : public WavStreamParser::Sink { AnimationId previous_ = AnimationId::None; bool started_ = false; int rejectCode_ = 0; - char body_[96] = {}; + const char* rejectMessage_ = nullptr; void reject(int code, const char* message) { rejectCode_ = code; - formatError(message); - } - - void formatError(const char* message) { - snprintf(body_, sizeof(body_), "{\"ok\":false,\"error\":\"%s\"}", message); + rejectMessage_ = message; } uint32_t playedMs() const { diff --git a/src/http/play_handlers.h b/src/http/play_handlers.h deleted file mode 100644 index f22dc8f9..00000000 --- a/src/http/play_handlers.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -#include - -// Called for each slice of the request body as it arrives. -void handlePlayBody(WebServer& server); -// Called once the whole body has been consumed. -void handlePlayDone(WebServer& server); diff --git a/src/http/request.cpp b/src/http/request.cpp new file mode 100644 index 00000000..c4127327 --- /dev/null +++ b/src/http/request.cpp @@ -0,0 +1,60 @@ +#include "http/request.h" + +#include + +#include "http/response.h" +#include "servos.h" + +namespace { + +bool parseIndex(const String& text, int& out) { + char* end = nullptr; + const long parsed = strtol(text.c_str(), &end, 10); + + if (text.isEmpty() || *end != '\0' || text[0] == '-' || text[0] == '+') { + return false; + } + + out = static_cast(parsed); + return true; +} + +bool parseAngle(const String& text, float& out) { + char* end = nullptr; + out = strtof(text.c_str(), &end); + return !text.isEmpty() && *end == '\0'; +} + +} // namespace + +std::optional ServoMove::read(WebServer& server) { + if (!server.hasArg("index") || !server.hasArg("angle")) { + sendError(server, 400, "missing index or angle"); + return std::nullopt; + } + + ServoMove move{}; + + if (!parseIndex(server.arg("index"), move.index)) { + sendError(server, 400, "invalid index"); + return std::nullopt; + } + + if (!parseAngle(server.arg("angle"), move.angle)) { + sendError(server, 400, "invalid angle"); + return std::nullopt; + } + + if (move.index >= SERVO_COUNT) { + sendError(server, 400, "index out of range"); + return std::nullopt; + } + + // Written so NaN fails too. + if (!(move.angle >= 0.0f && move.angle <= 180.0f)) { + sendError(server, 400, "angle out of range"); + return std::nullopt; + } + + return move; +} diff --git a/src/http/request.h b/src/http/request.h new file mode 100644 index 00000000..a671cdfb --- /dev/null +++ b/src/http/request.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#include + +struct ServoMove { + int index; + float angle; + + // Sends 400 and returns nullopt when the request holds no valid move. + static std::optional read(WebServer& server); +}; diff --git a/src/http/response.cpp b/src/http/response.cpp new file mode 100644 index 00000000..21c82bb4 --- /dev/null +++ b/src/http/response.cpp @@ -0,0 +1,15 @@ +#include "http/response.h" + +void sendJson(WebServer& server, int code, const JsonDocument& body) { + String text; + text.reserve(measureJson(body)); + serializeJson(body, text); + server.send(code, "application/json", text); +} + +void sendError(WebServer& server, int code, const char* message) { + JsonDocument body; + body["ok"] = false; + body["error"] = message; + sendJson(server, code, body); +} diff --git a/src/http/response.h b/src/http/response.h new file mode 100644 index 00000000..5de32942 --- /dev/null +++ b/src/http/response.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include + +void sendJson(WebServer& server, int code, const JsonDocument& body); +void sendError(WebServer& server, int code, const char* message); diff --git a/src/http/routes.cpp b/src/http/routes.cpp index a8289376..1d4fd23f 100644 --- a/src/http/routes.cpp +++ b/src/http/routes.cpp @@ -1,116 +1,132 @@ #include "http/routes.h" -#include -#include #include -#include "http/anim_handlers.h" -#include "http/health_handlers.h" -#include "http/index_page.h" -#include "http/json.h" -#include "http/play_handlers.h" -#include "http/server_context.h" -#include "http/settings_handlers.h" -#include "http/setup_handlers.h" -#include "http/test_handlers.h" +#include "http/handlers.h" +#include "http/middleware.h" +#include "http/response.h" #include "network/wifi_connect.h" +#include "web_ui.h" namespace { -void handleIndex() { - WebServer& server = httpServer(); +enum class Access { + Public, + Token, + // Token, and refused with 503 until WiFi credentials are saved. + Configured, +}; - if (wifiProvisioningMode() && server.uri() == "/") { - server.sendHeader( - "Location", - String("http://") + wifiApIpText() + "/config", - true - ); - server.send(302, "text/plain", "Redirecting to setup"); - return; +struct Route { + const char* path; + HTTPMethod method; + void (*handler)(WebServer&); + Access access; +}; + +constexpr Route kRoutes[] = { + {"/", HTTP_GET, handleIndex, Access::Public}, + {"/animations", HTTP_GET, handleIndex, Access::Public}, + {"/servo", HTTP_GET, handleIndex, Access::Public}, + {"/tests", HTTP_GET, handleIndex, Access::Public}, + {"/api", HTTP_GET, handleIndex, Access::Public}, + {"/config", HTTP_GET, handleIndex, Access::Public}, + {"/auth", HTTP_GET, handleAuth, Access::Public}, + {"/health", HTTP_GET, handleHealth, Access::Token}, + {"/anim", HTTP_GET, handleAnimGet, Access::Configured}, + {"/anim", HTTP_POST, handleAnimPost, Access::Configured}, + {"/settings", HTTP_GET, handleSettingsGet, Access::Token}, + {"/settings", HTTP_POST, handleSettingsPost, Access::Token}, + {"/settings/reset", HTTP_POST, handleSettingsReset, Access::Token}, + {"/settings/wifi", HTTP_PUT, handleSettingsWifiPut, Access::Token}, + {"/test/audio", HTTP_POST, handleAudioTest, Access::Configured}, + {"/test/audio/bell", HTTP_POST, handleBellTest, Access::Configured}, + {"/test/screen", HTTP_POST, handleScreenTest, Access::Configured}, + {"/test/movement", HTTP_POST, handleMovementTest, Access::Configured}, + {"/test/led", HTTP_POST, handleLedTest, Access::Configured}, + {"/test/servo", HTTP_POST, handleServoTest, Access::Configured}, + {"/setup/servo", HTTP_POST, handleSetupServo, Access::Token}, + {"/setup/led", HTTP_POST, handleSetupLed, Access::Token}, + {"/setup/audio", HTTP_POST, handleSetupAudio, Access::Token}, + {"/setup/oled", HTTP_POST, handleSetupOled, Access::Token}, +}; + +bool isPlayPath(const String& uri) { + return uri == "/play" || uri.startsWith("/play/"); +} + +bool isKnownPath(const String& uri) { + for (const Route& route : kRoutes) { + if (uri == route.path) { + return true; + } } - sendIndexPage(httpServer()); + return isPlayPath(uri); } -void handleNotFound() { - WebServer& server = httpServer(); +void registerRoute(WebServer& server, const Route& route) { + RequestHandler& handler = + server.on(route.path, route.method, [&server, fn = route.handler]() { fn(server); }); - if (server.method() == HTTP_OPTIONS) { - httpSendCorsPreflight(server); - return; + if (route.access == Access::Configured) { + handler.addMiddleware(requireWifiConfigured); + } + + if (route.access != Access::Public) { + handler.addMiddleware(requireApiAuth); } +} + +void redirectToSetup(WebServer& server) { + server.sendHeader("Location", String("http://") + wifiApIpText() + "/config", true); + server.send(302, "text/plain", "Redirecting to setup"); +} +void handleNotFound(WebServer& server) { if (wifiProvisioningMode() && server.method() == HTTP_GET) { - server.sendHeader("Location", String("http://") + wifiApIpText() + "/config", true); - server.send(302, "text/plain", "Redirecting to setup"); + redirectToSetup(server); return; } - if (isHttpTestPath(server.uri()) || isHttpSetupPath(server.uri()) || - isSettingsOrAnimPath(server.uri())) { - httpSendJson( - server, - 405, - "{\"ok\":false,\"error\":\"method not allowed\"}" - ); + if (isKnownPath(server.uri())) { + sendError(server, 405, "method not allowed"); return; } - httpSendJson( - server, - 404, - "{\"ok\":false,\"error\":\"not found\"}" - ); + sendError(server, 404, "not found"); } } // namespace -void registerHttpRoutes() { - WebServer& server = httpServer(); - - server.on("/", HTTP_GET, handleIndex); - server.on("/animations", HTTP_GET, handleIndex); - server.on("/servo", HTTP_GET, handleIndex); - server.on("/tests", HTTP_GET, handleIndex); - server.on("/api", HTTP_GET, handleIndex); - server.on("/config", HTTP_GET, handleIndex); - server.on("/auth", HTTP_GET, handleAuth); - server.on("/health", HTTP_GET, []() { - httpWithApiAuth(httpServer(), handleHealth); - }); - server.on("/anim", HTTP_GET, []() { - httpWithWifiAndApiAuth(httpServer(), handleAnimGet); - }); - server.on("/anim", HTTP_POST, []() { - httpWithWifiAndApiAuth(httpServer(), handleAnimPost); - }); +void handleIndex(WebServer& server) { + if (wifiProvisioningMode() && server.uri() == "/") { + redirectToSetup(server); + return; + } + + server.sendHeader("Content-Encoding", "gzip"); + server.sendHeader("Cache-Control", "no-cache"); + server.send_P(200, "text/html; charset=utf-8", reinterpret_cast(kWebUiGzip), sizeof(kWebUiGzip)); +} + +void registerHttpRoutes(WebServer& server) { + for (const Route& route : kRoutes) { + registerRoute(server, route); + } + // /play runs `talking`; /play/ picks another animation for the clip. - server.on( - "/play", - HTTP_POST, - []() { handlePlayDone(httpServer()); }, - []() { handlePlayBody(httpServer()); } - ); - server.on( - UriBraces("/play/{}"), - HTTP_POST, - []() { handlePlayDone(httpServer()); }, - []() { handlePlayBody(httpServer()); } - ); - server.on("/settings", HTTP_GET, []() { - httpWithApiAuth(httpServer(), handleSettingsGet); - }); - server.on("/settings", HTTP_POST, []() { - httpWithApiAuth(httpServer(), handleSettingsPost); - }); - server.on("/settings/reset", HTTP_POST, []() { - httpWithApiAuth(httpServer(), handleSettingsReset); - }); - server.on("/settings/wifi", HTTP_PUT, []() { - httpWithApiAuth(httpServer(), handleSettingsWifiPut); - }); - registerHttpTestRoutes(server); - registerHttpSetupRoutes(server); - server.onNotFound(handleNotFound); + // The session checks access itself, before the body streams. + auto onPlay = [&server](const Uri& uri) { + server.on( + uri, + HTTP_POST, + [&server]() { handlePlayDone(server); }, + [&server]() { handlePlayBody(server); } + ); + }; + onPlay(Uri("/play")); + onPlay(UriBraces("/play/{}")); + + server.onNotFound([&server]() { handleNotFound(server); }); } diff --git a/src/http/routes.h b/src/http/routes.h index 9f0fc371..42bbdd52 100644 --- a/src/http/routes.h +++ b/src/http/routes.h @@ -1,3 +1,5 @@ #pragma once -void registerHttpRoutes(); +#include + +void registerHttpRoutes(WebServer& server); diff --git a/src/http/server.cpp b/src/http/server.cpp new file mode 100644 index 00000000..e9228841 --- /dev/null +++ b/src/http/server.cpp @@ -0,0 +1,66 @@ +#include "http/server.h" + +#include +#include +#include + +#include "http/routes.h" +#include "network/wifi_connect.h" +#include "serial_log.h" +#include "settings/settings.h" + +namespace { + +WebServer server(80); +CorsMiddleware cors; + +bool serving() { + return wifiConnected() || wifiProvisioningMode(); +} + +void logUrl(const String& host, const char* path) { + serialLogPrintln(String("HTTP: http://") + host + path); +} + +} // namespace + +void startHttpServer() { + if (!serving()) { + serialLogPrintln("HTTP: skipped (no wifi)"); + return; + } + + // Origin drives CorsMiddleware; Authorization is the bearer token. + static const char* kCollectHeaders[] = {"Authorization", "Origin"}; + server.collectHeaders(kCollectHeaders, 2); + + cors.setOrigin("*") + .setMethods("GET, POST, PUT, OPTIONS") + .setHeaders("Authorization, Content-Type") + .setAllowCredentials(false); + server.addMiddleware(&cors); + + registerHttpRoutes(server); + server.begin(); + + if (wifiProvisioningMode()) { + logUrl(wifiApIpText(), "/config"); + return; + } + + const String mdnsHost = String(settingsHostname()) + ".local"; + + for (const char* path : {"/", "/health"}) { + logUrl(wifiIpText(), path); + logUrl(mdnsHost, path); + } +} + +void pollHttpServer() { + if (!serving()) { + delay(10); + return; + } + + server.handleClient(); +} diff --git a/src/http/http_server.h b/src/http/server.h similarity index 100% rename from src/http/http_server.h rename to src/http/server.h diff --git a/src/http/server_context.cpp b/src/http/server_context.cpp deleted file mode 100644 index bca760ce..00000000 --- a/src/http/server_context.cpp +++ /dev/null @@ -1,29 +0,0 @@ -#include "http/server_context.h" - -#include - -#include "settings/settings.h" - -namespace { - -WebServer server(80); -char mdnsHostname[SETTINGS_HOSTNAME_MAX_LEN + 7]; - -} // namespace - -WebServer& httpServer() { - return server; -} - -void refreshMdnsHostname() { - snprintf( - mdnsHostname, - sizeof(mdnsHostname), - "%s.local", - settingsHostname() - ); -} - -const char* httpMdnsHostname() { - return mdnsHostname; -} diff --git a/src/http/server_context.h b/src/http/server_context.h deleted file mode 100644 index 2845a9ae..00000000 --- a/src/http/server_context.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -#include - -WebServer& httpServer(); -void refreshMdnsHostname(); -const char* httpMdnsHostname(); diff --git a/src/http/settings_handlers.cpp b/src/http/settings_handlers.cpp index 4d2a5351..68042f88 100644 --- a/src/http/settings_handlers.cpp +++ b/src/http/settings_handlers.cpp @@ -1,5 +1,3 @@ -#include "http/settings_handlers.h" - #include #include #include @@ -7,8 +5,8 @@ #include #include "display/oled.h" -#include "http/json.h" -#include "http/server_context.h" +#include "http/handlers.h" +#include "http/response.h" #include "network/wifi_connect.h" #include "settings/settings.h" #include "settings/wifi_network_list.h" @@ -86,17 +84,14 @@ void sendSettingsJson( if (wifiConnectSuccess) { doc["wifi_connect_success"] = true; doc["wifi_ip"] = wifiIpText(); - doc["wifi_hostname"] = httpMdnsHostname(); + doc["wifi_hostname"] = String(settingsHostname()) + ".local"; } if (rebootRequired) { doc["reboot_required"] = true; } - String body; - body.reserve(measureJson(doc)); - serializeJson(doc, body); - httpSendJson(server, 200, body.c_str()); + sendJson(server, 200, doc); } } // namespace @@ -124,11 +119,7 @@ void handleSettingsPost(WebServer& server) { !hasContTo && !hasLoading && !hasEyesStyle && !hasAccessToken && !hasServoMins && !hasServoMaxs && !hasRgbOrder && !hasOledRotate180) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"missing sleep_timeout, hostname, volume, welcome, serial_log, continuous_timeout, loading, eyes_style, access_token, servo_mins, servo_maxs, rgb_order, or oled_rotate_180\"}" - ); + sendError(server, 400, "missing sleep_timeout, hostname, volume, welcome, serial_log, continuous_timeout, loading, eyes_style, access_token, servo_mins, servo_maxs, rgb_order, or oled_rotate_180"); return; } @@ -166,22 +157,14 @@ void handleSettingsPost(WebServer& server) { strtoul(sleepArg.c_str(), &end, 10); if (end == sleepArg.c_str() || *end != '\0') { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid sleep_timeout\"}" - ); + sendError(server, 400, "invalid sleep_timeout"); return; } sleepTimeoutMin = static_cast(parsed); if (!settingsValidateSleepTimeout(sleepTimeoutMin)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"sleep_timeout out of range\"}" - ); + sendError(server, 400, "sleep_timeout out of range"); return; } @@ -193,11 +176,7 @@ void handleSettingsPost(WebServer& server) { hostPtr = hostnameArg.c_str(); if (!settingsValidateHostname(hostPtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid hostname\"}" - ); + sendError(server, 400, "invalid hostname"); return; } } @@ -209,22 +188,14 @@ void handleSettingsPost(WebServer& server) { strtoul(volumeArg.c_str(), &end, 10); if (end == volumeArg.c_str() || *end != '\0' || parsed > 255) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid volume\"}" - ); + sendError(server, 400, "invalid volume"); return; } volume = static_cast(parsed); if (!settingsValidateVolume(volume)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"volume out of range\"}" - ); + sendError(server, 400, "volume out of range"); return; } @@ -238,11 +209,7 @@ void handleSettingsPost(WebServer& server) { strtoul(welcomeArg.c_str(), &end, 10); if (end == welcomeArg.c_str() || *end != '\0' || parsed > 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid welcome\"}" - ); + sendError(server, 400, "invalid welcome"); return; } @@ -257,11 +224,7 @@ void handleSettingsPost(WebServer& server) { strtoul(serialLogArg.c_str(), &end, 10); if (end == serialLogArg.c_str() || *end != '\0' || parsed > 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid serial_log\"}" - ); + sendError(server, 400, "invalid serial_log"); return; } @@ -276,22 +239,14 @@ void handleSettingsPost(WebServer& server) { strtoul(contArg.c_str(), &end, 10); if (end == contArg.c_str() || *end != '\0') { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid continuous_timeout\"}" - ); + sendError(server, 400, "invalid continuous_timeout"); return; } continuousTimeoutMin = static_cast(parsed); if (!settingsValidateContinuousTimeout(continuousTimeoutMin)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"continuous_timeout out of range\"}" - ); + sendError(server, 400, "continuous_timeout out of range"); return; } @@ -303,11 +258,7 @@ void handleSettingsPost(WebServer& server) { loadingPtr = loadingArg.c_str(); if (!settingsValidateLoading(loadingPtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid loading\"}" - ); + sendError(server, 400, "invalid loading"); return; } } @@ -317,11 +268,7 @@ void handleSettingsPost(WebServer& server) { eyesStylePtr = eyesStyleArg.c_str(); if (!settingsValidateEyesStyle(eyesStylePtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid eyes_style\"}" - ); + sendError(server, 400, "invalid eyes_style"); return; } } @@ -331,50 +278,30 @@ void handleSettingsPost(WebServer& server) { accessTokenPtr = accessTokenArg.c_str(); if (!settingsValidateAccessToken(accessTokenPtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid access_token\"}" - ); + sendError(server, 400, "invalid access_token"); return; } } if (hasServoMins || hasServoMaxs) { if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"servo setup only in AP mode\"}" - ); + sendError(server, 400, "servo setup only in AP mode"); return; } if (!hasServoMins || !hasServoMaxs) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"servo_mins and servo_maxs required together\"}" - ); + sendError(server, 400, "servo_mins and servo_maxs required together"); return; } if (!parseServoCsv(server.arg("servo_mins"), servoMins) || !parseServoCsv(server.arg("servo_maxs"), servoMaxs)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid servo_mins or servo_maxs\"}" - ); + sendError(server, 400, "invalid servo_mins or servo_maxs"); return; } if (!settingsValidateServoRanges(servoMins, servoMaxs)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"servo ranges out of range\"}" - ); + sendError(server, 400, "servo ranges out of range"); return; } @@ -384,22 +311,14 @@ void handleSettingsPost(WebServer& server) { if (hasRgbOrder) { if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"rgb setup only in AP mode\"}" - ); + sendError(server, 400, "rgb setup only in AP mode"); return; } rgbOrderArg = server.arg("rgb_order"); if (!settingsValidateRgbOrder(rgbOrderArg.c_str())) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid rgb_order\"}" - ); + sendError(server, 400, "invalid rgb_order"); return; } @@ -408,11 +327,7 @@ void handleSettingsPost(WebServer& server) { if (hasOledRotate180) { if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"oled setup only in AP mode\"}" - ); + sendError(server, 400, "oled setup only in AP mode"); return; } @@ -422,11 +337,7 @@ void handleSettingsPost(WebServer& server) { strtoul(oledArg.c_str(), &end, 10); if (end == oledArg.c_str() || *end != '\0' || parsed > 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid oled_rotate_180\"}" - ); + sendError(server, 400, "invalid oled_rotate_180"); return; } @@ -452,16 +363,10 @@ void handleSettingsPost(WebServer& server) { oledRotate180Ptr, &rebootRequired )) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"save failed\"}" - ); + sendError(server, 400, "save failed"); return; } - refreshMdnsHostname(); - if (oledRotate180Ptr != nullptr) { applyOledRotation(); restoreProvisioningOled(); @@ -475,11 +380,7 @@ void handleSettingsWifiPut(WebServer& server) { if (deserializeJson(body, server.arg("plain")) || !body.is()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"body must be a JSON array of networks\"}" - ); + sendError(server, 400, "body must be a JSON array of networks"); return; } @@ -488,11 +389,7 @@ void handleSettingsWifiPut(WebServer& server) { // An empty list would put the robot in setup AP mode at the next boot; // factory reset is the way to clear WiFi. if (entries.size() == 0) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"send at least one wifi network\"}" - ); + sendError(server, 400, "send at least one wifi network"); return; } @@ -506,11 +403,7 @@ void handleSettingsWifiPut(WebServer& server) { if (!entry["ssid"].is() || !(keepsPassword || passwordField.is())) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"each network needs a string ssid and an optional string password\"}" - ); + sendError(server, 400, "each network needs a string ssid and an optional string password"); return; } @@ -519,20 +412,12 @@ void handleSettingsWifiPut(WebServer& server) { : passwordField.as(); if (password == nullptr) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"password required for a new network\"}" - ); + sendError(server, 400, "password required for a new network"); return; } if (!next.add(ssid, password)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid ssid or password, duplicate ssid, or more than 5 networks\"}" - ); + sendError(server, 400, "invalid ssid or password, duplicate ssid, or more than 5 networks"); return; } } @@ -543,14 +428,7 @@ void handleSettingsWifiPut(WebServer& server) { if (tested && !wifiTestCredentials(next.at(0).ssid, next.at(0).password)) { - char error[96]; - snprintf( - error, - sizeof(error), - "{\"ok\":false,\"error\":\"%s\"}", - wifiLastConnectError() - ); - httpSendJson(server, 400, error); + sendError(server, 400, wifiLastConnectError()); return; } @@ -559,11 +437,7 @@ void handleSettingsWifiPut(WebServer& server) { wifiRestoreProvisioningAp(); } - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"save failed\"}" - ); + sendError(server, 400, "save failed"); return; } @@ -580,20 +454,9 @@ void handleSettingsReset(WebServer& server) { bool rebootRequired = false; if (!factoryResetSettings(&rebootRequired)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"factory reset failed\"}" - ); + sendError(server, 400, "factory reset failed"); return; } sendSettingsJson(server, rebootRequired, false); } - -bool isSettingsOrAnimPath(const String& uri) { - return uri == "/anim" || uri == "/play" || uri.startsWith("/play/") || - uri == "/settings" || uri == "/settings/reset" || - uri == "/settings/wifi" || - uri == "/auth" || uri == "/setup/servo"; -} diff --git a/src/http/settings_handlers.h b/src/http/settings_handlers.h deleted file mode 100644 index d10cc606..00000000 --- a/src/http/settings_handlers.h +++ /dev/null @@ -1,10 +0,0 @@ -#pragma once - -#include -#include - -void handleSettingsGet(WebServer& server); -void handleSettingsPost(WebServer& server); -void handleSettingsReset(WebServer& server); -void handleSettingsWifiPut(WebServer& server); -bool isSettingsOrAnimPath(const String& uri); diff --git a/src/http/setup_handlers.cpp b/src/http/setup_handlers.cpp index 726b0fc0..67446f34 100644 --- a/src/http/setup_handlers.cpp +++ b/src/http/setup_handlers.cpp @@ -1,392 +1,180 @@ -#include "http/setup_handlers.h" - #include -#include -#include "http/json.h" -#include "hardware/pca9685_servos.h" -#include "hardware/rgb.h" #include "audio/audio.h" #include "display/oled.h" +#include "hardware/pca9685_servos.h" +#include "hardware/rgb.h" +#include "http/handlers.h" +#include "http/request.h" +#include "http/response.h" #include "network/wifi_connect.h" -#include "servos.h" #include "settings/settings.h" namespace { -bool isDigitsOnly(const String& s) { - if (s.length() == 0) { - return false; +// Setup routes drive the hardware without the saved limits, so they only run in setup AP mode. +bool inSetupMode(WebServer& server, const char* step) { + if (wifiProvisioningMode()) { + return true; } - for (unsigned i = 0; i < s.length(); i++) { - if (s[i] < '0' || s[i] > '9') { - return false; - } - } + sendError(server, 400, (String("setup ") + step + " only in AP mode").c_str()); + return false; +} - return true; +JsonDocument setupReply(const char* step) { + JsonDocument body; + body["ok"] = true; + body["setup"] = step; + return body; } -bool isValidFloatArg(const String& s) { - if (s.length() == 0) { - return false; +void holdLogicalColor(WebServer& server, const String& color) { + if (color != "R" && color != "G" && color != "B") { + sendError(server, 400, "invalid color"); + return; } - unsigned i = 0; + const char* order = nullptr; + const String orderArg = server.arg("rgb_order"); - if (s[0] == '+' || s[0] == '-') { - i = 1; - if (i >= s.length()) { - return false; + if (server.hasArg("rgb_order")) { + if (!settingsValidateRgbOrder(orderArg.c_str())) { + sendError(server, 400, "invalid rgb_order"); + return; } - } - bool sawDigit = false; - bool sawDot = false; - - for (; i < s.length(); i++) { - const char c = s[i]; + order = orderArg.c_str(); + } - if (c >= '0' && c <= '9') { - sawDigit = true; - continue; - } + rgbSetupHoldLogical(color == "R" ? 255 : 0, color == "G" ? 255 : 0, color == "B" ? 255 : 0, order); - if (c == '.' && !sawDot) { - sawDot = true; - continue; - } + JsonDocument body = setupReply("led"); + body["color"] = color; + sendJson(server, 200, body); +} - return false; +void holdWireByte(WebServer& server, const String& byte) { + if (byte != "0" && byte != "1" && byte != "2") { + sendError(server, 400, "invalid byte"); + return; } - return sawDigit; + rgbSetupHoldWireByte(static_cast(byte.toInt())); + + JsonDocument body = setupReply("led"); + body["byte"] = byte.toInt(); + sendJson(server, 200, body); } +} // namespace + void handleSetupServo(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup servo only in AP mode\"}" - ); + if (!inSetupMode(server, "servo")) { return; } if (server.hasArg("all")) { if (server.arg("all") != "90") { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid all\"}" - ); + sendError(server, 400, "invalid all"); return; } servoMoveAllToElectricalAngle(90.0f); - httpSendJson(server, 200, "{\"ok\":true,\"setup\":\"servo\",\"all\":90}"); - return; - } - if (!server.hasArg("index") || !server.hasArg("angle")) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"missing index or angle\"}" - ); + JsonDocument body = setupReply("servo"); + body["all"] = 90; + sendJson(server, 200, body); return; } - const String indexArg = server.arg("index"); - const String angleArg = server.arg("angle"); + const std::optional move = ServoMove::read(server); - if (!isDigitsOnly(indexArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid index\"}" - ); + if (!move) { return; } - if (!isValidFloatArg(angleArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid angle\"}" - ); - return; - } - - const int index = indexArg.toInt(); - const float angle = angleArg.toFloat(); - - if (index < 0 || index >= SERVO_COUNT) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); + if (!moveServoSmoothElectrical(move->index, move->angle)) { + sendError(server, 400, "index out of range"); return; } - if (angle < 0.0f || angle > 180.0f) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"angle out of range\"}" - ); - return; - } - - if (!moveServoSmoothElectrical(index, angle)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); - return; - } - - char body[96]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"setup\":\"servo\",\"index\":%d,\"angle\":%g}", - index, - (double)angle - ); - - httpSendJson(server, 200, body); + JsonDocument body = setupReply("servo"); + body["index"] = move->index; + body["angle"] = move->angle; + sendJson(server, 200, body); } void handleSetupLed(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup led only in AP mode\"}" - ); + if (!inSetupMode(server, "led")) { return; } - const bool hasColor = server.hasArg("color"); - const bool hasByte = server.hasArg("byte"); - const String byteArg = hasByte ? server.arg("byte") : ""; - const bool byteOff = hasByte && byteArg == "off"; - - if (hasColor && hasByte && !byteOff) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"color and byte conflict\"}" - ); - return; - } + const String byte = server.arg("byte"); + const bool release = !server.hasArg("byte") || byte == "off"; - if (hasColor) { - const String colorArg = server.arg("color"); - uint8_t r = 0; - uint8_t g = 0; - uint8_t b = 0; - - if (colorArg == "R") { - r = 255; - } else if (colorArg == "G") { - g = 255; - } else if (colorArg == "B") { - b = 255; - } else { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid color\"}" - ); + if (server.hasArg("color")) { + if (!release) { + sendError(server, 400, "color and byte conflict"); return; } - const char* orderPtr = nullptr; - String orderArg; - - if (server.hasArg("rgb_order")) { - orderArg = server.arg("rgb_order"); - - if (!settingsValidateRgbOrder(orderArg.c_str())) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid rgb_order\"}" - ); - return; - } - - orderPtr = orderArg.c_str(); - } - - rgbSetupHoldLogical(r, g, b, orderPtr); - - char body[72]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"setup\":\"led\",\"color\":\"%s\"}", - colorArg.c_str() - ); - - httpSendJson(server, 200, body); - return; - } - - if (!hasByte || byteOff) { - rgbSetupRelease(); - httpSendJson(server, 200, "{\"ok\":true,\"setup\":\"led\",\"off\":true}"); + holdLogicalColor(server, server.arg("color")); return; } - if (!isDigitsOnly(byteArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid byte\"}" - ); - return; - } - - const int byteIndex = byteArg.toInt(); - - if (byteIndex < 0 || byteIndex > 2) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"byte out of range\"}" - ); + if (!release) { + holdWireByte(server, byte); return; } - rgbSetupHoldWireByte(static_cast(byteIndex)); + rgbSetupRelease(); - char body[64]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"setup\":\"led\",\"byte\":%d}", - byteIndex - ); - - httpSendJson(server, 200, body); + JsonDocument body = setupReply("led"); + body["off"] = true; + sendJson(server, 200, body); } void handleSetupAudio(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup audio only in AP mode\"}" - ); + if (!inSetupMode(server, "audio")) { return; } - if (!playWelcome()) { - showIdleScreen(); - httpSendJson( - server, - 500, - "{\"ok\":false,\"error\":\"welcome playback failed\"}" - ); + const bool played = playWelcome(); + showIdleScreen(); + + if (!played) { + sendError(server, 500, "welcome playback failed"); return; } - showIdleScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"setup\":\"audio\"}"); + sendJson(server, 200, setupReply("audio")); } void handleSetupOled(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup oled only in AP mode\"}" - ); + if (!inSetupMode(server, "oled")) { return; } if (!server.hasArg("rotate_180")) { restoreProvisioningOled(); - httpSendJson( - server, - 200, - "{\"ok\":true,\"setup\":\"oled\",\"restored\":true}" - ); - return; - } - - const String rotArg = server.arg("rotate_180"); - if (!isDigitsOnly(rotArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid rotate_180\"}" - ); + JsonDocument body = setupReply("oled"); + body["restored"] = true; + sendJson(server, 200, body); return; } - const int parsed = rotArg.toInt(); + const String rotate = server.arg("rotate_180"); - if (parsed != 0 && parsed != 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid rotate_180\"}" - ); + if (rotate != "0" && rotate != "1") { + sendError(server, 400, "invalid rotate_180"); return; } - const bool rotate180 = parsed == 1; - showOledOrientationTest(rotate180); - - char body[72]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"setup\":\"oled\",\"rotate_180\":%s}", - rotate180 ? "true" : "false" - ); - - httpSendJson(server, 200, body); -} - -} // namespace - -bool isHttpSetupPath(const String& uri) { - return uri == "/setup/servo" || uri == "/setup/led" || - uri == "/setup/audio" || uri == "/setup/oled"; -} + showOledOrientationTest(rotate == "1"); -void registerHttpSetupRoutes(WebServer& server) { - server.on( - "/setup/servo", - HTTP_POST, - [&server]() { httpWithApiAuth(server, handleSetupServo); } - ); - server.on( - "/setup/led", - HTTP_POST, - [&server]() { httpWithApiAuth(server, handleSetupLed); } - ); - server.on( - "/setup/audio", - HTTP_POST, - [&server]() { httpWithApiAuth(server, handleSetupAudio); } - ); - server.on( - "/setup/oled", - HTTP_POST, - [&server]() { httpWithApiAuth(server, handleSetupOled); } - ); + JsonDocument body = setupReply("oled"); + body["rotate_180"] = rotate == "1"; + sendJson(server, 200, body); } diff --git a/src/http/setup_handlers.h b/src/http/setup_handlers.h deleted file mode 100644 index 21bf5584..00000000 --- a/src/http/setup_handlers.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -#include -#include - -void registerHttpSetupRoutes(WebServer& server); -bool isHttpSetupPath(const String& uri); diff --git a/src/http/test_handlers.cpp b/src/http/test_handlers.cpp index bfd31083..2ee80c44 100644 --- a/src/http/test_handlers.cpp +++ b/src/http/test_handlers.cpp @@ -1,227 +1,78 @@ -#include "http/test_handlers.h" - #include #include "animation.h" #include "audio/audio.h" #include "display/oled.h" -#include "http/json.h" #include "hardware/pca9685_servos.h" #include "hardware/rgb.h" +#include "http/handlers.h" +#include "http/request.h" +#include "http/response.h" #include "servos.h" namespace { -void restoreReadyScreen() { +// Every test leaves the OLED on its idle screen before it answers. +void finishTest(WebServer& server, const char* test) { showIdleScreen(); + + JsonDocument body; + body["ok"] = true; + body["test"] = test; + sendJson(server, 200, body); } +} // namespace + void handleAudioTest(WebServer& server) { runSoundTest(); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"audio\"}"); + finishTest(server, "audio"); } void handleBellTest(WebServer& server) { if (!playBell()) { - restoreReadyScreen(); - httpSendJson( - server, - 500, - "{\"ok\":false,\"error\":\"bell playback failed\"}" - ); + showIdleScreen(); + sendError(server, 500, "bell playback failed"); return; } - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"bell\"}"); + finishTest(server, "bell"); } void handleScreenTest(WebServer& server) { runOledTest(); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"screen\"}"); + finishTest(server, "screen"); } void handleMovementTest(WebServer& server) { runServoTest(); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"movement\"}"); + finishTest(server, "movement"); } void handleLedTest(WebServer& server) { runRgbTest(); setRgbForAnimation(getAnimation(), millis()); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"led\"}"); -} - -bool isDigitsOnly(const String& s) { - if (s.length() == 0) { - return false; - } - - for (unsigned i = 0; i < s.length(); i++) { - if (s[i] < '0' || s[i] > '9') { - return false; - } - } - - return true; -} - -bool isValidFloatArg(const String& s) { - if (s.length() == 0) { - return false; - } - - unsigned i = 0; - - if (s[0] == '+' || s[0] == '-') { - i = 1; - if (i >= s.length()) { - return false; - } - } - - bool sawDigit = false; - bool sawDot = false; - - for (; i < s.length(); i++) { - const char c = s[i]; - - if (c >= '0' && c <= '9') { - sawDigit = true; - continue; - } - - if (c == '.' && !sawDot) { - sawDot = true; - continue; - } - - return false; - } - - return sawDigit; + finishTest(server, "led"); } void handleServoTest(WebServer& server) { - if (!server.hasArg("index") || !server.hasArg("angle")) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"missing index or angle\"}" - ); - return; - } - - const String indexArg = server.arg("index"); - const String angleArg = server.arg("angle"); - - if (!isDigitsOnly(indexArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid index\"}" - ); - return; - } - - if (!isValidFloatArg(angleArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid angle\"}" - ); - return; - } - - const int index = indexArg.toInt(); - const float angle = angleArg.toFloat(); - - if (index < 0 || index >= SERVO_COUNT) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); - return; - } + const std::optional move = ServoMove::read(server); - if (angle < 0.0f || angle > 180.0f) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"angle out of range\"}" - ); + if (!move) { return; } - if (!moveServoSmooth(index, angle)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); + if (!moveServoSmooth(move->index, move->angle)) { + sendError(server, 400, "index out of range"); return; } - restoreReadyScreen(); - - char body[96]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"test\":\"servo\",\"index\":%d,\"angle\":%g}", - index, - (double)angle - ); - - httpSendJson(server, 200, body); -} - -} // namespace - -bool isHttpTestPath(const String& uri) { - return uri == "/test/audio" || - uri == "/test/audio/bell" || - uri == "/test/screen" || - uri == "/test/movement" || - uri == "/test/led" || - uri == "/test/servo"; -} + showIdleScreen(); -void registerHttpTestRoutes(WebServer& server) { - server.on( - "/test/audio", - HTTP_POST, - [&server]() { httpWithWifiAndApiAuth(server, handleAudioTest); } - ); - server.on( - "/test/audio/bell", - HTTP_POST, - [&server]() { httpWithWifiAndApiAuth(server, handleBellTest); } - ); - server.on( - "/test/screen", - HTTP_POST, - [&server]() { httpWithWifiAndApiAuth(server, handleScreenTest); } - ); - server.on( - "/test/movement", - HTTP_POST, - [&server]() { httpWithWifiAndApiAuth(server, handleMovementTest); } - ); - server.on( - "/test/led", - HTTP_POST, - [&server]() { httpWithWifiAndApiAuth(server, handleLedTest); } - ); - server.on( - "/test/servo", - HTTP_POST, - [&server]() { httpWithWifiAndApiAuth(server, handleServoTest); } - ); + JsonDocument body; + body["ok"] = true; + body["test"] = "servo"; + body["index"] = move->index; + body["angle"] = move->angle; + sendJson(server, 200, body); } diff --git a/src/http/test_handlers.h b/src/http/test_handlers.h deleted file mode 100644 index bf5423fe..00000000 --- a/src/http/test_handlers.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -#include -#include - -void registerHttpTestRoutes(WebServer& server); -bool isHttpTestPath(const String& uri); diff --git a/src/main.cpp b/src/main.cpp index d1214a8e..a0c7067c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,7 +15,7 @@ #include "hardware/servo_wrapper.h" #include "network/wifi_connect.h" #include "network/ota.h" -#include "http/http_server.h" +#include "http/server.h" #include "settings/settings.h" #include "serial_log.h" #include "sleep.h" From 2386990fe3f5e2dac3e2f67d2819cf824a98990a Mon Sep 17 00:00:00 2001 From: shodan Date: Sun, 11 Oct 2026 15:06:02 +0200 Subject: [PATCH 02/12] refactor(settings): keep the settings in one struct and save them from a patch saveSettings takes a SettingsPatch of optional fields instead of 13 nullable pointers. The RAM cache is one SettingsValues struct that loads, saves and logs itself, so the per-field globals, cache setters and 13-argument NVS writers are gone. ServoRanges checks itself and SettingsPatch::invalidField names the first bad field. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/settings.md | 19 +- src/http/settings_handlers.cpp | 46 ++-- src/settings/cache.cpp | 91 ------- src/settings/getters.cpp | 32 +-- src/settings/internal.h | 30 --- src/settings/load.cpp | 129 +--------- src/settings/nvs.cpp | 237 ++++++++++-------- src/settings/nvs.h | 48 ---- src/settings/reset.cpp | 57 ----- src/settings/save.cpp | 215 ---------------- src/settings/settings.h | 55 ++-- src/settings/validate.cpp | 56 +++-- src/settings/values.cpp | 85 +++++++ src/settings/values.h | 47 ++++ .../test_settings_validate.cpp | 37 ++- 15 files changed, 427 insertions(+), 757 deletions(-) delete mode 100644 src/settings/cache.cpp delete mode 100644 src/settings/internal.h delete mode 100644 src/settings/reset.cpp delete mode 100644 src/settings/save.cpp create mode 100644 src/settings/values.cpp create mode 100644 src/settings/values.h diff --git a/docs/settings.md b/docs/settings.md index 5583b23c..3a7e98d4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -13,8 +13,8 @@ Follow the same pattern for a new setting. | Choice | Convention | | --- | --- | | Storage | NVS under namespace `te`; short key name (≤15 chars for Preferences) | -| RAM | Cached in a module-static after `initSettings()`; callers use getters | -| Update API | Nullable pointer args to `saveSettings(...)` — only non-null fields change | +| RAM | One `SettingsValues` struct (`g_settings`) filled by `initSettings()`; callers use getters | +| Update API | `saveSettings(const SettingsPatch&, ...)`: only the fields the patch sets change | | HTTP | Query params on `POST /settings`; at least one param required | | Apply timing | Prefer immediate apply. If boot-only (like hostname), set `reboot_required` and document it. WiFi networks live in a `WifiNetworkList` saved with `saveWifiNetworks()`, outside `saveSettings(...)`; they are tested before save only in setup AP mode. Servo min/max (`servo_mins` / `servo_maxs`), RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are setup-AP-only and apply immediately | | Types | Prefer small integers / short strings; validate before any NVS write | @@ -25,16 +25,13 @@ Work through these layers in order. Mirror an existing setting (`volume` is the ### 1. Core — [`src/settings/`](../src/settings/) -Public API: [`settings.h`](../src/settings/settings.h). RAM cache: [`internal.h`](../src/settings/internal.h) / [`cache.cpp`](../src/settings/cache.cpp). NVS keys: [`nvs.h`](../src/settings/nvs.h) / [`nvs.cpp`](../src/settings/nvs.cpp). Load: [`load.cpp`](../src/settings/load.cpp). Save: [`save.cpp`](../src/settings/save.cpp). Reset: [`reset.cpp`](../src/settings/reset.cpp). Getters: [`getters.cpp`](../src/settings/getters.cpp). Validation: [`validate.cpp`](../src/settings/validate.cpp). +Public API: [`settings.h`](../src/settings/settings.h). Values struct: [`values.h`](../src/settings/values.h) / [`values.cpp`](../src/settings/values.cpp). NVS keys, load, save and reset: [`nvs.h`](../src/settings/nvs.h) / [`nvs.cpp`](../src/settings/nvs.cpp). Boot: [`load.cpp`](../src/settings/load.cpp). Getters: [`getters.cpp`](../src/settings/getters.cpp). Validation: [`validate.cpp`](../src/settings/validate.cpp). 1. Add `SETTINGS_DEFAULT_*`, min/max (or length) constants in [`settings.h`](../src/settings/settings.h). -2. Add NVS key string in [`nvs.h`](../src/settings/nvs.h) (e.g. `kKeyFoo = "foo"`) and persist it in `writeAllToNvs` / `settingsNvsPutAll` in [`nvs.cpp`](../src/settings/nvs.cpp). -3. Add RAM cache variable in [`internal.h`](../src/settings/internal.h) / [`cache.cpp`](../src/settings/cache.cpp); load + validate in `initSettings()` ([`load.cpp`](../src/settings/load.cpp)); fall back to default on bad/missing data. -4. Add getter `settingsFoo()` in [`getters.cpp`](../src/settings/getters.cpp) and `settingsValidateFoo(...)` in [`validate.cpp`](../src/settings/validate.cpp). -5. Extend `saveSettings(...)` in [`save.cpp`](../src/settings/save.cpp) with a nullable `const T* foo`: - - Reject the whole save if validation fails. - - Write all persisted fields together (current pattern rewrites sleep/host/volume/welcome/serial_log/continuous_timeout/loading/access_token each save; servo/rgb/oled too). -6. Log the new value on load and save via `logSettingsSnapshot` in [`cache.cpp`](../src/settings/cache.cpp) (for secrets like `access_token`, log set/unset only — never the raw value). +2. Add the field to `SettingsValues` in [`values.h`](../src/settings/values.h), set its default in `SettingsValues::defaults()` and log it in `SettingsValues::log()` ([`values.cpp`](../src/settings/values.cpp)). For secrets like `access_token`, log set/unset only, never the raw value. +3. Add the NVS key in [`nvs.h`](../src/settings/nvs.h) (e.g. `kKeyFoo = "foo"`). Read it in `SettingsValues::load()` (keep the default on bad or missing data) and write it in `SettingsValues::save()` ([`nvs.cpp`](../src/settings/nvs.cpp)). +4. Add `settingsValidateFoo(...)` in [`validate.cpp`](../src/settings/validate.cpp) and the getter `settingsFoo()` in [`getters.cpp`](../src/settings/getters.cpp). +5. Add the field to `SettingsPatch` in [`settings.h`](../src/settings/settings.h) (`std::optional`, or `const char*` for text), check it in `SettingsPatch::invalidField()` and copy it in `SettingsValues::apply()`. `saveSettings()` rejects the whole patch when a field is invalid and writes every key together. ### 2. Consumers @@ -86,7 +83,7 @@ Flash only when you want to try it on hardware (`pio run -t upload`). ## Pitfalls - **Partial writes:** validate first; never write NVS then fail validation mid-way. -- **Factory reset:** `factoryResetSettings()` in [`reset.cpp`](../src/settings/reset.cpp) clears NVS namespace `te` and writes defaults, which clears every saved WiFi network. Servo min/max (`sranges`), RGB LED mapping (`rgb_ord`), and OLED rotation (`oled_rot`) are written back unchanged. Exposed as `POST /settings/reset`. After reset, power-cycle into setup AP mode to configure WiFi again. Servo ranges, LED mapping, and screen rotation can be retuned in that wizard. +- **Factory reset:** `factoryResetSettings()` in [`nvs.cpp`](../src/settings/nvs.cpp) clears NVS namespace `te` and writes defaults, which clears every saved WiFi network. Servo min/max (`sranges`), RGB LED mapping (`rgb_ord`), and OLED rotation (`oled_rot`) are written back unchanged. Exposed as `POST /settings/reset`. After reset, power-cycle into setup AP mode to configure WiFi again. Servo ranges, LED mapping, and screen rotation can be retuned in that wizard. - **Hostname-style settings:** freeze the boot value separately if live change cannot apply (see `settingsBootHostname()` / `reboot_required`). - **UI size:** the panel is embedded gzipped in the firmware; keep controls compact. - **Doc drift:** HTML param tables must match `api.md` exactly. diff --git a/src/http/settings_handlers.cpp b/src/http/settings_handlers.cpp index 68042f88..ae0b89e1 100644 --- a/src/http/settings_handlers.cpp +++ b/src/http/settings_handlers.cpp @@ -300,7 +300,11 @@ void handleSettingsPost(WebServer& server) { return; } - if (!settingsValidateServoRanges(servoMins, servoMaxs)) { + ServoRanges ranges{}; + memcpy(ranges.mins, servoMins, SETTINGS_SERVO_COUNT); + memcpy(ranges.maxs, servoMaxs, SETTINGS_SERVO_COUNT); + + if (!ranges.valid()) { sendError(server, 400, "servo ranges out of range"); return; } @@ -347,22 +351,30 @@ void handleSettingsPost(WebServer& server) { bool rebootRequired = false; - if (!saveSettings( - sleepPtr, - hostPtr, - volumePtr, - welcomePtr, - serialLogPtr, - contToPtr, - loadingPtr, - eyesStylePtr, - accessTokenPtr, - servoMinsPtr, - servoMaxsPtr, - rgbOrderPtr, - oledRotate180Ptr, - &rebootRequired - )) { + SettingsPatch patch; + auto optional = [](const auto* value) { + return value == nullptr ? std::nullopt : std::make_optional(*value); + }; + patch.sleepTimeoutMin = optional(sleepPtr); + patch.hostname = hostPtr; + patch.volume = optional(volumePtr); + patch.welcome = optional(welcomePtr); + patch.serialLog = optional(serialLogPtr); + patch.continuousTimeoutMin = optional(contToPtr); + patch.loading = loadingPtr; + patch.eyesStyle = eyesStylePtr; + patch.accessToken = accessTokenPtr; + patch.rgbOrder = rgbOrderPtr; + patch.oledRotate180 = optional(oledRotate180Ptr); + + if (servoMinsPtr != nullptr) { + ServoRanges ranges{}; + memcpy(ranges.mins, servoMinsPtr, SETTINGS_SERVO_COUNT); + memcpy(ranges.maxs, servoMaxsPtr, SETTINGS_SERVO_COUNT); + patch.servoRanges = ranges; + } + + if (!saveSettings(patch, &rebootRequired)) { sendError(server, 400, "save failed"); return; } diff --git a/src/settings/cache.cpp b/src/settings/cache.cpp deleted file mode 100644 index 81c7923c..00000000 --- a/src/settings/cache.cpp +++ /dev/null @@ -1,91 +0,0 @@ -#include "settings/internal.h" -#include "serial_log.h" -#include "settings/settings.h" - -#include - -uint32_t g_sleepTimeoutMin = SETTINGS_DEFAULT_SLEEP_TIMEOUT_MIN; -char g_hostname[SETTINGS_HOSTNAME_MAX_LEN + 1] = {}; -char g_bootHostname[SETTINGS_HOSTNAME_MAX_LEN + 1] = {}; -uint8_t g_volume = SETTINGS_DEFAULT_VOLUME; -bool g_welcome = SETTINGS_DEFAULT_WELCOME; -bool g_serialLog = SETTINGS_DEFAULT_SERIAL_LOG; -uint32_t g_continuousTimeoutMin = SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN; -char g_loading[SETTINGS_LOADING_MAX_LEN + 1] = {}; -char g_eyesStyle[SETTINGS_EYES_STYLE_MAX_LEN + 1] = {}; -char g_accessToken[SETTINGS_ACCESS_TOKEN_MAX_LEN + 1] = {}; -WifiNetworkList g_wifiNetworks; -uint8_t g_servoMin[SETTINGS_SERVO_COUNT] = {}; -uint8_t g_servoMax[SETTINGS_SERVO_COUNT] = {}; -char g_rgbOrder[SETTINGS_RGB_ORDER_MAX_LEN + 1] = {}; -bool g_oledRotate180 = SETTINGS_DEFAULT_OLED_ROTATE_180; - -void setHostnameCache(char* dest, const char* src) { - strncpy(dest, src, SETTINGS_HOSTNAME_MAX_LEN); - dest[SETTINGS_HOSTNAME_MAX_LEN] = '\0'; -} - -void setLoadingCache(char* dest, const char* src) { - strncpy(dest, src, SETTINGS_LOADING_MAX_LEN); - dest[SETTINGS_LOADING_MAX_LEN] = '\0'; -} - -void setEyesStyleCache(char* dest, const char* src) { - strncpy(dest, src, SETTINGS_EYES_STYLE_MAX_LEN); - dest[SETTINGS_EYES_STYLE_MAX_LEN] = '\0'; -} - -void setAccessTokenCache(char* dest, const char* src) { - strncpy(dest, src, SETTINGS_ACCESS_TOKEN_MAX_LEN); - dest[SETTINGS_ACCESS_TOKEN_MAX_LEN] = '\0'; -} - -void setRgbOrderCache(char* dest, const char* src) { - strncpy(dest, src, SETTINGS_RGB_ORDER_MAX_LEN); - dest[SETTINGS_RGB_ORDER_MAX_LEN] = '\0'; -} - -void logAccessTokenState() { - serialLogPrint(" access_token="); - serialLogPrint(g_accessToken[0] != '\0' ? "set" : "unset"); -} - -void logSettingsSnapshot(const char* prefix) { - serialLogPrint(prefix); - serialLogPrint(" sleep_timeout="); - serialLogPrint(g_sleepTimeoutMin); - serialLogPrint("min hostname="); - serialLogPrint(g_hostname); - serialLogPrint(" volume="); - serialLogPrint(g_volume); - serialLogPrint(" welcome="); - serialLogPrint(g_welcome ? "on" : "off"); - serialLogPrint(" serial_log="); - serialLogPrint(g_serialLog ? "on" : "off"); - serialLogPrint(" continuous_timeout="); - serialLogPrint(g_continuousTimeoutMin); - serialLogPrint("min loading="); - serialLogPrint(g_loading); - serialLogPrint(" eyes_style="); - serialLogPrint(g_eyesStyle); - logAccessTokenState(); - serialLogPrint(" wifi_networks="); - serialLogPrint(g_wifiNetworks.count()); - serialLogPrint(" servos="); - - for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { - if (i > 0) { - serialLogPrint(";"); - } - - serialLogPrint(g_servoMin[i]); - serialLogPrint("-"); - serialLogPrint(g_servoMax[i]); - } - - serialLogPrint(" rgb_order="); - serialLogPrint(g_rgbOrder); - serialLogPrint(" oled_rotate_180="); - serialLogPrint(g_oledRotate180 ? "on" : "off"); - serialLogPrintln(); -} diff --git a/src/settings/getters.cpp b/src/settings/getters.cpp index a95df96a..a3f2cd62 100644 --- a/src/settings/getters.cpp +++ b/src/settings/getters.cpp @@ -1,5 +1,5 @@ #include "settings/settings.h" -#include "settings/internal.h" +#include "settings/values.h" #include "servos.h" static_assert( @@ -8,15 +8,15 @@ static_assert( ); uint32_t settingsSleepTimeoutMin() { - return g_sleepTimeoutMin; + return g_settings.sleepTimeoutMin; } uint32_t settingsSleepTimeoutMs() { - return g_sleepTimeoutMin * 60UL * 1000UL; + return g_settings.sleepTimeoutMin * 60UL * 1000UL; } const char* settingsHostname() { - return g_hostname; + return g_settings.hostname; } const char* settingsBootHostname() { @@ -24,35 +24,35 @@ const char* settingsBootHostname() { } uint8_t settingsVolume() { - return g_volume; + return g_settings.volume; } bool settingsWelcomeEnabled() { - return g_welcome; + return g_settings.welcome; } bool settingsSerialLogEnabled() { - return g_serialLog; + return g_settings.serialLog; } uint32_t settingsContinuousTimeoutMin() { - return g_continuousTimeoutMin; + return g_settings.continuousTimeoutMin; } const char* settingsLoading() { - return g_loading; + return g_settings.loading; } const char* settingsEyesStyle() { - return g_eyesStyle; + return g_settings.eyesStyle; } const char* settingsAccessToken() { - return g_accessToken; + return g_settings.accessToken; } bool settingsAccessTokenSet() { - return g_accessToken[0] != '\0'; + return g_settings.accessToken[0] != '\0'; } const WifiNetworkList& settingsWifiNetworks() { @@ -68,7 +68,7 @@ float settingsServoMin(int index) { return 0.0f; } - return static_cast(g_servoMin[index]); + return static_cast(g_settings.servoRanges.mins[index]); } float settingsServoMax(int index) { @@ -76,13 +76,13 @@ float settingsServoMax(int index) { return static_cast(SETTINGS_SERVO_ANGLE_MAX); } - return static_cast(g_servoMax[index]); + return static_cast(g_settings.servoRanges.maxs[index]); } const char* settingsRgbOrder() { - return g_rgbOrder; + return g_settings.rgbOrder; } bool settingsOledRotate180() { - return g_oledRotate180; + return g_settings.oledRotate180; } diff --git a/src/settings/internal.h b/src/settings/internal.h deleted file mode 100644 index 24e87b6a..00000000 --- a/src/settings/internal.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include - -#include "settings/settings.h" -#include "settings/wifi_network_list.h" - -extern uint32_t g_sleepTimeoutMin; -extern char g_hostname[SETTINGS_HOSTNAME_MAX_LEN + 1]; -extern char g_bootHostname[SETTINGS_HOSTNAME_MAX_LEN + 1]; -extern uint8_t g_volume; -extern bool g_welcome; -extern bool g_serialLog; -extern uint32_t g_continuousTimeoutMin; -extern char g_loading[SETTINGS_LOADING_MAX_LEN + 1]; -extern char g_eyesStyle[SETTINGS_EYES_STYLE_MAX_LEN + 1]; -extern char g_accessToken[SETTINGS_ACCESS_TOKEN_MAX_LEN + 1]; -extern WifiNetworkList g_wifiNetworks; -extern uint8_t g_servoMin[SETTINGS_SERVO_COUNT]; -extern uint8_t g_servoMax[SETTINGS_SERVO_COUNT]; -extern char g_rgbOrder[SETTINGS_RGB_ORDER_MAX_LEN + 1]; -extern bool g_oledRotate180; - -void setHostnameCache(char* dest, const char* src); -void setLoadingCache(char* dest, const char* src); -void setEyesStyleCache(char* dest, const char* src); -void setAccessTokenCache(char* dest, const char* src); -void setRgbOrderCache(char* dest, const char* src); -void logAccessTokenState(); -void logSettingsSnapshot(const char* prefix); diff --git a/src/settings/load.cpp b/src/settings/load.cpp index 564e3eba..f89e1a10 100644 --- a/src/settings/load.cpp +++ b/src/settings/load.cpp @@ -1,10 +1,6 @@ -#include "settings/internal.h" -#include "settings/nvs.h" #include "serial_log.h" -#include "settings/settings.h" - -#include -#include +#include "settings/nvs.h" +#include "settings/values.h" namespace { @@ -51,126 +47,23 @@ void migrateLegacyWifi() { } // namespace void initSettings() { - setHostnameCache(g_hostname, SETTINGS_DEFAULT_HOSTNAME); - g_volume = SETTINGS_DEFAULT_VOLUME; - g_welcome = SETTINGS_DEFAULT_WELCOME; - g_serialLog = SETTINGS_DEFAULT_SERIAL_LOG; - g_continuousTimeoutMin = SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN; - setLoadingCache(g_loading, SETTINGS_DEFAULT_LOADING); - setEyesStyleCache(g_eyesStyle, SETTINGS_DEFAULT_EYES_STYLE); - setAccessTokenCache(g_accessToken, SETTINGS_DEFAULT_ACCESS_TOKEN); + g_settings = SettingsValues::defaults(); g_wifiNetworks.clear(); - fillDefaultServoRanges(g_servoMin, g_servoMax); - setRgbOrderCache(g_rgbOrder, SETTINGS_DEFAULT_RGB_ORDER); - g_oledRotate180 = SETTINGS_DEFAULT_OLED_ROTATE_180; - - if (!settingsNvsBeginRead()) { - serialLogPrintln("Settings: NVS open failed; using defaults"); - setHostnameCache(g_bootHostname, g_hostname); - return; - } - - g_sleepTimeoutMin = g_settingsPrefs.getUInt( - kKeySleep, - SETTINGS_DEFAULT_SLEEP_TIMEOUT_MIN - ); - - if (!settingsValidateSleepTimeout(g_sleepTimeoutMin)) { - g_sleepTimeoutMin = SETTINGS_DEFAULT_SLEEP_TIMEOUT_MIN; - } - - String host = g_settingsPrefs.getString(kKeyHost, SETTINGS_DEFAULT_HOSTNAME); - - if (settingsValidateHostname(host.c_str())) { - setHostnameCache(g_hostname, host.c_str()); - } else { - setHostnameCache(g_hostname, SETTINGS_DEFAULT_HOSTNAME); - } - - const uint32_t vol = g_settingsPrefs.getUInt(kKeyVolume, SETTINGS_DEFAULT_VOLUME); - - if (vol <= SETTINGS_VOLUME_MAX && - settingsValidateVolume(static_cast(vol))) { - g_volume = static_cast(vol); - } else { - g_volume = SETTINGS_DEFAULT_VOLUME; - } - - g_welcome = g_settingsPrefs.getBool(kKeyWelcome, SETTINGS_DEFAULT_WELCOME); - g_serialLog = g_settingsPrefs.getBool(kKeySerialLog, SETTINGS_DEFAULT_SERIAL_LOG); - - g_continuousTimeoutMin = g_settingsPrefs.getUInt( - kKeyContTo, - SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN - ); - - if (!settingsValidateContinuousTimeout(g_continuousTimeoutMin)) { - g_continuousTimeoutMin = SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN; - } + bool legacyWifi = false; - String loading = g_settingsPrefs.getString(kKeyLoading, SETTINGS_DEFAULT_LOADING); - - if (settingsValidateLoading(loading.c_str())) { - setLoadingCache(g_loading, loading.c_str()); - } else { - setLoadingCache(g_loading, SETTINGS_DEFAULT_LOADING); - } - - String eyesStyle = - g_settingsPrefs.getString(kKeyEyesStyle, SETTINGS_DEFAULT_EYES_STYLE); - - if (settingsValidateEyesStyle(eyesStyle.c_str())) { - setEyesStyleCache(g_eyesStyle, eyesStyle.c_str()); - } else { - setEyesStyleCache(g_eyesStyle, SETTINGS_DEFAULT_EYES_STYLE); - } - - String accessTok = - g_settingsPrefs.getString(kKeyAccessTok, SETTINGS_DEFAULT_ACCESS_TOKEN); - - if (settingsValidateAccessToken(accessTok.c_str())) { - setAccessTokenCache(g_accessToken, accessTok.c_str()); - } else { - setAccessTokenCache(g_accessToken, SETTINGS_DEFAULT_ACCESS_TOKEN); - } - - const bool legacyWifi = loadWifiNetworks(); - - uint8_t servoBlob[kServoRangesLen] = {}; - const size_t servoBytes = - g_settingsPrefs.getBytes(kKeyServoRanges, servoBlob, kServoRangesLen); - - if (servoBytes == kServoRangesLen) { - uint8_t loadedMin[SETTINGS_SERVO_COUNT] = {}; - uint8_t loadedMax[SETTINGS_SERVO_COUNT] = {}; - unpackServoRanges(servoBlob, loadedMin, loadedMax); - - if (settingsValidateServoRanges(loadedMin, loadedMax)) { - memcpy(g_servoMin, loadedMin, SETTINGS_SERVO_COUNT); - memcpy(g_servoMax, loadedMax, SETTINGS_SERVO_COUNT); - } - } - - String rgbOrder = g_settingsPrefs.getString(kKeyRgbOrder, SETTINGS_DEFAULT_RGB_ORDER); - - if (settingsValidateRgbOrder(rgbOrder.c_str())) { - setRgbOrderCache(g_rgbOrder, rgbOrder.c_str()); + if (settingsNvsBeginRead()) { + g_settings.load(); + legacyWifi = loadWifiNetworks(); + settingsNvsEnd(); } else { - setRgbOrderCache(g_rgbOrder, SETTINGS_DEFAULT_RGB_ORDER); + serialLogPrintln("Settings: NVS open failed; using defaults"); } - g_oledRotate180 = g_settingsPrefs.getBool( - kKeyOledRot, - SETTINGS_DEFAULT_OLED_ROTATE_180 - ); - - settingsNvsEnd(); - - setHostnameCache(g_bootHostname, g_hostname); + copyText(g_bootHostname, g_settings.hostname); if (legacyWifi) { migrateLegacyWifi(); } - logSettingsSnapshot("Settings:"); + g_settings.log("Settings:"); } diff --git a/src/settings/nvs.cpp b/src/settings/nvs.cpp index b3b503c1..29c5b9a5 100644 --- a/src/settings/nvs.cpp +++ b/src/settings/nvs.cpp @@ -1,39 +1,34 @@ #include "settings/nvs.h" #include "serial_log.h" -#include "servos.h" +#include "settings/values.h" Preferences g_settingsPrefs; -void fillDefaultServoRanges(uint8_t* mins, uint8_t* maxs) { - for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { - mins[i] = static_cast(SERVO_SPECS[i].min); - maxs[i] = static_cast(SERVO_SPECS[i].max); - } -} +namespace { -void packServoRanges( - uint8_t* blob, - const uint8_t* mins, - const uint8_t* maxs -) { - for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { - blob[i * 2] = mins[i]; - blob[i * 2 + 1] = maxs[i]; +// Stored as min,max pairs, one per servo. +constexpr size_t kServoRangesLen = SETTINGS_SERVO_COUNT * 2; + +template +void loadText(char (&dest)[N], const char* key, bool (*valid)(const char*)) { + const String value = g_settingsPrefs.getString(key, dest); + + if (valid(value.c_str())) { + copyText(dest, value.c_str()); } } -void unpackServoRanges( - const uint8_t* blob, - uint8_t* mins, - uint8_t* maxs -) { - for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { - mins[i] = blob[i * 2]; - maxs[i] = blob[i * 2 + 1]; +void loadMinutes(uint32_t& dest, const char* key, bool (*valid)(uint32_t)) { + const uint32_t value = g_settingsPrefs.getUInt(key, dest); + + if (valid(value)) { + dest = value; } } +} // namespace + bool settingsNvsBeginRead() { return g_settingsPrefs.begin(kNs, true); } @@ -46,29 +41,58 @@ void settingsNvsEnd() { g_settingsPrefs.end(); } -void settingsNvsClear() { - g_settingsPrefs.clear(); +void SettingsValues::load() { + loadMinutes(sleepTimeoutMin, kKeySleep, settingsValidateSleepTimeout); + loadText(hostname, kKeyHost, settingsValidateHostname); + + const uint32_t storedVolume = g_settingsPrefs.getUInt(kKeyVolume, volume); + + if (storedVolume <= SETTINGS_VOLUME_MAX) { + volume = static_cast(storedVolume); + } + + welcome = g_settingsPrefs.getBool(kKeyWelcome, welcome); + serialLog = g_settingsPrefs.getBool(kKeySerialLog, serialLog); + loadMinutes(continuousTimeoutMin, kKeyContTo, settingsValidateContinuousTimeout); + loadText(loading, kKeyLoading, settingsValidateLoading); + loadText(eyesStyle, kKeyEyesStyle, settingsValidateEyesStyle); + loadText(accessToken, kKeyAccessTok, settingsValidateAccessToken); + + uint8_t blob[kServoRangesLen] = {}; + + if (g_settingsPrefs.getBytes(kKeyServoRanges, blob, kServoRangesLen) == kServoRangesLen) { + ServoRanges stored{}; + + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + stored.mins[i] = blob[i * 2]; + stored.maxs[i] = blob[i * 2 + 1]; + } + + if (stored.valid()) { + servoRanges = stored; + } + } + + loadText(rgbOrder, kKeyRgbOrder, settingsValidateRgbOrder); + oledRotate180 = g_settingsPrefs.getBool(kKeyOledRot, oledRotate180); } -namespace { +bool SettingsValues::save(bool clearFirst) const { + if (!settingsNvsBeginWrite()) { + serialLogPrintln("Settings: NVS write open failed"); + return false; + } + + if (clearFirst) { + g_settingsPrefs.clear(); + } -void putAll( - uint32_t sleepTimeoutMin, - const char* hostname, - uint8_t volume, - bool welcome, - bool serialLog, - uint32_t continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - bool oledRotate180 -) { - uint8_t servoBlob[kServoRangesLen]; - packServoRanges(servoBlob, servoMins, servoMaxs); + uint8_t blob[kServoRangesLen]; + + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + blob[i * 2] = servoRanges.mins[i]; + blob[i * 2 + 1] = servoRanges.maxs[i]; + } g_settingsPrefs.putUInt(kKeySleep, sleepTimeoutMin); g_settingsPrefs.putString(kKeyHost, hostname); @@ -79,80 +103,83 @@ void putAll( g_settingsPrefs.putString(kKeyLoading, loading); g_settingsPrefs.putString(kKeyEyesStyle, eyesStyle); g_settingsPrefs.putString(kKeyAccessTok, accessToken); - g_settingsPrefs.putBytes(kKeyServoRanges, servoBlob, kServoRangesLen); + g_settingsPrefs.putBytes(kKeyServoRanges, blob, kServoRangesLen); g_settingsPrefs.putString(kKeyRgbOrder, rgbOrder); g_settingsPrefs.putBool(kKeyOledRot, oledRotate180); + settingsNvsEnd(); + return true; } -} // namespace +bool saveSettings(const SettingsPatch& patch, bool* rebootRequired) { + if (rebootRequired != nullptr) { + *rebootRequired = false; + } + + if (patch.invalidField() != nullptr) { + return false; + } + + SettingsValues next = g_settings; + next.apply(patch); + + if (!next.save()) { + return false; + } + + g_settings = next; + + if (rebootRequired != nullptr) { + *rebootRequired = strcmp(g_settings.hostname, g_bootHostname) != 0; + } + + g_settings.log("Settings saved:"); + return true; +} -bool writeAllToNvs( - uint32_t sleepTimeoutMin, - const char* hostname, - uint8_t volume, - bool welcome, - bool serialLog, - uint32_t continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - bool oledRotate180 -) { +bool saveWifiNetworks(const WifiNetworkList& networks) { if (!settingsNvsBeginWrite()) { serialLogPrintln("Settings: NVS write open failed"); return false; } - putAll( - sleepTimeoutMin, - hostname, - volume, - welcome, - serialLog, - continuousTimeoutMin, - loading, - eyesStyle, - accessToken, - servoMins, - servoMaxs, - rgbOrder, - oledRotate180 - ); + const size_t length = networks.count() * sizeof(WifiNetwork); + const bool written = + g_settingsPrefs.putBytes(kKeyWifiNets, networks.data(), length) == length; + settingsNvsEnd(); + + if (!written) { + serialLogPrintln("Settings: WiFi networks write failed"); + return false; + } + + g_wifiNetworks = networks; + g_settings.log("Settings saved:"); return true; } -void settingsNvsPutAll( - uint32_t sleepTimeoutMin, - const char* hostname, - uint8_t volume, - bool welcome, - bool serialLog, - uint32_t continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - bool oledRotate180 -) { - putAll( - sleepTimeoutMin, - hostname, - volume, - welcome, - serialLog, - continuousTimeoutMin, - loading, - eyesStyle, - accessToken, - servoMins, - servoMaxs, - rgbOrder, - oledRotate180 - ); +bool factoryResetSettings(bool* rebootRequired) { + const SettingsValues defaults = SettingsValues::defaults(); + + if (rebootRequired != nullptr) { + *rebootRequired = strcmp(g_bootHostname, defaults.hostname) != 0 || + strcmp(g_settings.loading, defaults.loading) != 0 || + g_wifiNetworks.count() > 0; + } + + // The hardware calibration survives a reset. + SettingsValues next = defaults; + next.servoRanges = g_settings.servoRanges; + copyText(next.rgbOrder, g_settings.rgbOrder); + next.oledRotate180 = g_settings.oledRotate180; + + if (!next.save(true)) { + serialLogPrintln("Settings: factory reset NVS open failed"); + return false; + } + + g_settings = next; + g_wifiNetworks.clear(); + g_settings.log("Settings factory reset:"); + return true; } diff --git a/src/settings/nvs.h b/src/settings/nvs.h index 8f558a34..c104f1cd 100644 --- a/src/settings/nvs.h +++ b/src/settings/nvs.h @@ -3,8 +3,6 @@ #include #include -#include "settings/settings.h" - constexpr const char* kNs = "te"; constexpr const char* kKeySleep = "sleep_m"; constexpr const char* kKeyHost = "host"; @@ -22,55 +20,9 @@ constexpr const char* kKeyWifiNets = "wifi_nets"; constexpr const char* kKeyServoRanges = "sranges"; constexpr const char* kKeyRgbOrder = "rgb_ord"; constexpr const char* kKeyOledRot = "oled_rot"; -constexpr size_t kServoRangesLen = SETTINGS_SERVO_COUNT * 2; extern Preferences g_settingsPrefs; -void fillDefaultServoRanges(uint8_t* mins, uint8_t* maxs); -void packServoRanges( - uint8_t* blob, - const uint8_t* mins, - const uint8_t* maxs -); -void unpackServoRanges( - const uint8_t* blob, - uint8_t* mins, - uint8_t* maxs -); - bool settingsNvsBeginRead(); bool settingsNvsBeginWrite(); void settingsNvsEnd(); -void settingsNvsClear(); - -void settingsNvsPutAll( - uint32_t sleepTimeoutMin, - const char* hostname, - uint8_t volume, - bool welcome, - bool serialLog, - uint32_t continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - bool oledRotate180 -); - -bool writeAllToNvs( - uint32_t sleepTimeoutMin, - const char* hostname, - uint8_t volume, - bool welcome, - bool serialLog, - uint32_t continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - bool oledRotate180 -); diff --git a/src/settings/reset.cpp b/src/settings/reset.cpp deleted file mode 100644 index 17a8cdc1..00000000 --- a/src/settings/reset.cpp +++ /dev/null @@ -1,57 +0,0 @@ -#include "settings/internal.h" -#include "settings/nvs.h" -#include "serial_log.h" -#include "settings/settings.h" - -#include - -bool factoryResetSettings(bool* rebootRequired) { - const bool hostReboot = - strcmp(g_bootHostname, SETTINGS_DEFAULT_HOSTNAME) != 0; - const bool loadingReboot = - strcmp(g_loading, SETTINGS_DEFAULT_LOADING) != 0; - const bool wifiReboot = g_wifiNetworks.count() > 0; - - if (rebootRequired != nullptr) { - *rebootRequired = hostReboot || loadingReboot || wifiReboot; - } - - g_sleepTimeoutMin = SETTINGS_DEFAULT_SLEEP_TIMEOUT_MIN; - setHostnameCache(g_hostname, SETTINGS_DEFAULT_HOSTNAME); - g_volume = SETTINGS_DEFAULT_VOLUME; - g_welcome = SETTINGS_DEFAULT_WELCOME; - g_serialLog = SETTINGS_DEFAULT_SERIAL_LOG; - g_continuousTimeoutMin = SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN; - setLoadingCache(g_loading, SETTINGS_DEFAULT_LOADING); - setEyesStyleCache(g_eyesStyle, SETTINGS_DEFAULT_EYES_STYLE); - setAccessTokenCache(g_accessToken, SETTINGS_DEFAULT_ACCESS_TOKEN); - g_wifiNetworks.clear(); - // Keep g_servoMin / g_servoMax, g_rgbOrder, and g_oledRotate180; write - // them back after prefs.clear(). - - if (!settingsNvsBeginWrite()) { - serialLogPrintln("Settings: factory reset NVS open failed"); - return false; - } - - settingsNvsClear(); - settingsNvsPutAll( - g_sleepTimeoutMin, - g_hostname, - g_volume, - g_welcome, - g_serialLog, - g_continuousTimeoutMin, - g_loading, - g_eyesStyle, - g_accessToken, - g_servoMin, - g_servoMax, - g_rgbOrder, - g_oledRotate180 - ); - settingsNvsEnd(); - - logSettingsSnapshot("Settings factory reset:"); - return true; -} diff --git a/src/settings/save.cpp b/src/settings/save.cpp deleted file mode 100644 index 8f86d0a6..00000000 --- a/src/settings/save.cpp +++ /dev/null @@ -1,215 +0,0 @@ -#include "settings/internal.h" -#include "settings/nvs.h" -#include "serial_log.h" -#include "settings/settings.h" - -#include - -bool saveSettings( - const uint32_t* sleepTimeoutMin, - const char* hostname, - const uint8_t* volume, - const bool* welcome, - const bool* serialLog, - const uint32_t* continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - const bool* oledRotate180, - bool* rebootRequired -) { - if (rebootRequired != nullptr) { - *rebootRequired = false; - } - - if (sleepTimeoutMin == nullptr && - hostname == nullptr && - volume == nullptr && - welcome == nullptr && - serialLog == nullptr && - continuousTimeoutMin == nullptr && - loading == nullptr && - eyesStyle == nullptr && - accessToken == nullptr && - servoMins == nullptr && - servoMaxs == nullptr && - rgbOrder == nullptr && - oledRotate180 == nullptr) { - return false; - } - - uint32_t nextSleep = g_sleepTimeoutMin; - char nextHost[SETTINGS_HOSTNAME_MAX_LEN + 1]; - setHostnameCache(nextHost, g_hostname); - uint8_t nextVolume = g_volume; - bool nextWelcome = g_welcome; - bool nextSerialLog = g_serialLog; - uint32_t nextContTo = g_continuousTimeoutMin; - char nextLoading[SETTINGS_LOADING_MAX_LEN + 1]; - setLoadingCache(nextLoading, g_loading); - char nextEyesStyle[SETTINGS_EYES_STYLE_MAX_LEN + 1]; - setEyesStyleCache(nextEyesStyle, g_eyesStyle); - char nextAccessToken[SETTINGS_ACCESS_TOKEN_MAX_LEN + 1]; - setAccessTokenCache(nextAccessToken, g_accessToken); - uint8_t nextServoMin[SETTINGS_SERVO_COUNT]; - uint8_t nextServoMax[SETTINGS_SERVO_COUNT]; - memcpy(nextServoMin, g_servoMin, SETTINGS_SERVO_COUNT); - memcpy(nextServoMax, g_servoMax, SETTINGS_SERVO_COUNT); - char nextRgbOrder[SETTINGS_RGB_ORDER_MAX_LEN + 1]; - setRgbOrderCache(nextRgbOrder, g_rgbOrder); - bool nextOledRotate180 = g_oledRotate180; - - if (sleepTimeoutMin != nullptr) { - if (!settingsValidateSleepTimeout(*sleepTimeoutMin)) { - return false; - } - - nextSleep = *sleepTimeoutMin; - } - - if (hostname != nullptr) { - if (!settingsValidateHostname(hostname)) { - return false; - } - - setHostnameCache(nextHost, hostname); - } - - if (volume != nullptr) { - if (!settingsValidateVolume(*volume)) { - return false; - } - - nextVolume = *volume; - } - - if (welcome != nullptr) { - nextWelcome = *welcome; - } - - if (serialLog != nullptr) { - nextSerialLog = *serialLog; - } - - if (continuousTimeoutMin != nullptr) { - if (!settingsValidateContinuousTimeout(*continuousTimeoutMin)) { - return false; - } - - nextContTo = *continuousTimeoutMin; - } - - if (loading != nullptr) { - if (!settingsValidateLoading(loading)) { - return false; - } - - setLoadingCache(nextLoading, loading); - } - - if (eyesStyle != nullptr) { - if (!settingsValidateEyesStyle(eyesStyle)) { - return false; - } - - setEyesStyleCache(nextEyesStyle, eyesStyle); - } - - if (accessToken != nullptr) { - if (!settingsValidateAccessToken(accessToken)) { - return false; - } - - setAccessTokenCache(nextAccessToken, accessToken); - } - - if (servoMins != nullptr || servoMaxs != nullptr) { - if (servoMins == nullptr || servoMaxs == nullptr) { - return false; - } - - if (!settingsValidateServoRanges(servoMins, servoMaxs)) { - return false; - } - - memcpy(nextServoMin, servoMins, SETTINGS_SERVO_COUNT); - memcpy(nextServoMax, servoMaxs, SETTINGS_SERVO_COUNT); - } - - if (rgbOrder != nullptr) { - if (!settingsValidateRgbOrder(rgbOrder)) { - return false; - } - - setRgbOrderCache(nextRgbOrder, rgbOrder); - } - - if (oledRotate180 != nullptr) { - nextOledRotate180 = *oledRotate180; - } - - if (!writeAllToNvs( - nextSleep, - nextHost, - nextVolume, - nextWelcome, - nextSerialLog, - nextContTo, - nextLoading, - nextEyesStyle, - nextAccessToken, - nextServoMin, - nextServoMax, - nextRgbOrder, - nextOledRotate180 - )) { - return false; - } - - g_sleepTimeoutMin = nextSleep; - setHostnameCache(g_hostname, nextHost); - g_volume = nextVolume; - g_welcome = nextWelcome; - g_serialLog = nextSerialLog; - g_continuousTimeoutMin = nextContTo; - setLoadingCache(g_loading, nextLoading); - setEyesStyleCache(g_eyesStyle, nextEyesStyle); - setAccessTokenCache(g_accessToken, nextAccessToken); - memcpy(g_servoMin, nextServoMin, SETTINGS_SERVO_COUNT); - memcpy(g_servoMax, nextServoMax, SETTINGS_SERVO_COUNT); - setRgbOrderCache(g_rgbOrder, nextRgbOrder); - g_oledRotate180 = nextOledRotate180; - - if (rebootRequired != nullptr && - strcmp(g_hostname, g_bootHostname) != 0) { - *rebootRequired = true; - } - - logSettingsSnapshot("Settings saved:"); - return true; -} - -bool saveWifiNetworks(const WifiNetworkList& networks) { - if (!settingsNvsBeginWrite()) { - serialLogPrintln("Settings: NVS write open failed"); - return false; - } - - const size_t length = networks.count() * sizeof(WifiNetwork); - const bool written = - g_settingsPrefs.putBytes(kKeyWifiNets, networks.data(), length) == length; - - settingsNvsEnd(); - - if (!written) { - serialLogPrintln("Settings: WiFi networks write failed"); - return false; - } - - g_wifiNetworks = networks; - logSettingsSnapshot("Settings saved:"); - return true; -} diff --git a/src/settings/settings.h b/src/settings/settings.h index b2da8a70..bea5a869 100644 --- a/src/settings/settings.h +++ b/src/settings/settings.h @@ -2,6 +2,7 @@ #include #include +#include class WifiNetworkList; @@ -34,6 +35,33 @@ constexpr size_t SETTINGS_RGB_ORDER_MAX_LEN = 3; constexpr const char* SETTINGS_DEFAULT_RGB_ORDER = "GRB"; constexpr bool SETTINGS_DEFAULT_OLED_ROTATE_180 = false; +struct ServoRanges { + uint8_t mins[SETTINGS_SERVO_COUNT]; + uint8_t maxs[SETTINGS_SERVO_COUNT]; + + // Every angle within 0..SETTINGS_SERVO_ANGLE_MAX and each min below its max. + bool valid() const; +}; + +// Fields left empty, or nullptr for text, keep their saved value. +struct SettingsPatch { + std::optional sleepTimeoutMin; + const char* hostname = nullptr; + std::optional volume; + std::optional welcome; + std::optional serialLog; + std::optional continuousTimeoutMin; + const char* loading = nullptr; + const char* eyesStyle = nullptr; + const char* accessToken = nullptr; + std::optional servoRanges; + const char* rgbOrder = nullptr; + std::optional oledRotate180; + + // API name of the first field set to an invalid value, or nullptr. + const char* invalidField() const; +}; + void initSettings(); uint32_t settingsSleepTimeoutMin(); @@ -66,31 +94,12 @@ bool settingsValidateEyesStyle(const char* eyesStyle); bool settingsValidateAccessToken(const char* accessToken); bool settingsValidateWifiSsid(const char* wifiSsid); bool settingsValidateWifiPassword(const char* wifiPassword); -bool settingsValidateServoRanges( - const uint8_t* servoMins, - const uint8_t* servoMaxs -); bool settingsValidateRgbOrder(const char* rgbOrder); -// Updates RAM cache and NVS for any non-null args. Returns false on validation -// failure (no write). If hostname is set and differs from boot hostname, -// *rebootRequired is set true when non-null. -bool saveSettings( - const uint32_t* sleepTimeoutMin, - const char* hostname, - const uint8_t* volume, - const bool* welcome, - const bool* serialLog, - const uint32_t* continuousTimeoutMin, - const char* loading, - const char* eyesStyle, - const char* accessToken, - const uint8_t* servoMins, - const uint8_t* servoMaxs, - const char* rgbOrder, - const bool* oledRotate180, - bool* rebootRequired -); +// Saves the fields the patch sets and keeps the others. False when a field is +// invalid (nothing is written) or NVS fails. *rebootRequired is set when the +// saved hostname differs from the boot hostname. +bool saveSettings(const SettingsPatch& patch, bool* rebootRequired); // Replaces every saved network in RAM and NVS. bool saveWifiNetworks(const WifiNetworkList& networks); diff --git a/src/settings/validate.cpp b/src/settings/validate.cpp index d0b97f20..be4d9405 100644 --- a/src/settings/validate.cpp +++ b/src/settings/validate.cpp @@ -103,21 +103,9 @@ bool settingsValidateWifiPassword(const char* wifiPassword) { return strlen(wifiPassword) <= SETTINGS_WIFI_PASSWORD_MAX_LEN; } -bool settingsValidateServoRanges( - const uint8_t* servoMins, - const uint8_t* servoMaxs -) { - if (servoMins == nullptr || servoMaxs == nullptr) { - return false; - } - +bool ServoRanges::valid() const { for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { - if (servoMins[i] > SETTINGS_SERVO_ANGLE_MAX || - servoMaxs[i] > SETTINGS_SERVO_ANGLE_MAX) { - return false; - } - - if (servoMins[i] >= servoMaxs[i]) { + if (maxs[i] > SETTINGS_SERVO_ANGLE_MAX || mins[i] >= maxs[i]) { return false; } } @@ -137,3 +125,43 @@ bool settingsValidateRgbOrder(const char* rgbOrder) { strcmp(rgbOrder, "BRG") == 0 || strcmp(rgbOrder, "BGR") == 0; } + +const char* SettingsPatch::invalidField() const { + if (sleepTimeoutMin && !settingsValidateSleepTimeout(*sleepTimeoutMin)) { + return "sleep_timeout"; + } + + if (hostname != nullptr && !settingsValidateHostname(hostname)) { + return "hostname"; + } + + if (volume && !settingsValidateVolume(*volume)) { + return "volume"; + } + + if (continuousTimeoutMin && !settingsValidateContinuousTimeout(*continuousTimeoutMin)) { + return "continuous_timeout"; + } + + if (loading != nullptr && !settingsValidateLoading(loading)) { + return "loading"; + } + + if (eyesStyle != nullptr && !settingsValidateEyesStyle(eyesStyle)) { + return "eyes_style"; + } + + if (accessToken != nullptr && !settingsValidateAccessToken(accessToken)) { + return "access_token"; + } + + if (servoRanges && !servoRanges->valid()) { + return "servo_mins or servo_maxs"; + } + + if (rgbOrder != nullptr && !settingsValidateRgbOrder(rgbOrder)) { + return "rgb_order"; + } + + return nullptr; +} diff --git a/src/settings/values.cpp b/src/settings/values.cpp new file mode 100644 index 00000000..435a25ea --- /dev/null +++ b/src/settings/values.cpp @@ -0,0 +1,85 @@ +#include "settings/values.h" + +#include "serial_log.h" +#include "servos.h" + +SettingsValues g_settings = SettingsValues::defaults(); +WifiNetworkList g_wifiNetworks; +char g_bootHostname[SETTINGS_HOSTNAME_MAX_LEN + 1] = {}; + +SettingsValues SettingsValues::defaults() { + SettingsValues values{}; + + values.sleepTimeoutMin = SETTINGS_DEFAULT_SLEEP_TIMEOUT_MIN; + copyText(values.hostname, SETTINGS_DEFAULT_HOSTNAME); + values.volume = SETTINGS_DEFAULT_VOLUME; + values.welcome = SETTINGS_DEFAULT_WELCOME; + values.serialLog = SETTINGS_DEFAULT_SERIAL_LOG; + values.continuousTimeoutMin = SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN; + copyText(values.loading, SETTINGS_DEFAULT_LOADING); + copyText(values.eyesStyle, SETTINGS_DEFAULT_EYES_STYLE); + copyText(values.accessToken, SETTINGS_DEFAULT_ACCESS_TOKEN); + + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + values.servoRanges.mins[i] = static_cast(SERVO_SPECS[i].min); + values.servoRanges.maxs[i] = static_cast(SERVO_SPECS[i].max); + } + + copyText(values.rgbOrder, SETTINGS_DEFAULT_RGB_ORDER); + values.oledRotate180 = SETTINGS_DEFAULT_OLED_ROTATE_180; + return values; +} + +void SettingsValues::apply(const SettingsPatch& patch) { + sleepTimeoutMin = patch.sleepTimeoutMin.value_or(sleepTimeoutMin); + volume = patch.volume.value_or(volume); + welcome = patch.welcome.value_or(welcome); + serialLog = patch.serialLog.value_or(serialLog); + continuousTimeoutMin = patch.continuousTimeoutMin.value_or(continuousTimeoutMin); + servoRanges = patch.servoRanges.value_or(servoRanges); + oledRotate180 = patch.oledRotate180.value_or(oledRotate180); + + if (patch.hostname != nullptr) { + copyText(hostname, patch.hostname); + } + + if (patch.loading != nullptr) { + copyText(loading, patch.loading); + } + + if (patch.eyesStyle != nullptr) { + copyText(eyesStyle, patch.eyesStyle); + } + + if (patch.accessToken != nullptr) { + copyText(accessToken, patch.accessToken); + } + + if (patch.rgbOrder != nullptr) { + copyText(rgbOrder, patch.rgbOrder); + } +} + +void SettingsValues::log(const char* prefix) const { + String line = prefix; + + line += String(" sleep_timeout=") + sleepTimeoutMin + "min"; + line += String(" hostname=") + hostname; + line += String(" volume=") + volume; + line += String(" welcome=") + (welcome ? "on" : "off"); + line += String(" serial_log=") + (serialLog ? "on" : "off"); + line += String(" continuous_timeout=") + continuousTimeoutMin + "min"; + line += String(" loading=") + loading; + line += String(" eyes_style=") + eyesStyle; + line += String(" access_token=") + (accessToken[0] != '\0' ? "set" : "unset"); + line += String(" wifi_networks=") + g_wifiNetworks.count(); + line += " servos="; + + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + line += String(i > 0 ? ";" : "") + servoRanges.mins[i] + "-" + servoRanges.maxs[i]; + } + + line += String(" rgb_order=") + rgbOrder; + line += String(" oled_rotate_180=") + (oledRotate180 ? "on" : "off"); + serialLogPrintln(line); +} diff --git a/src/settings/values.h b/src/settings/values.h new file mode 100644 index 00000000..8e8e7841 --- /dev/null +++ b/src/settings/values.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "settings/settings.h" +#include "settings/wifi_network_list.h" + +template +void copyText(char (&dest)[N], const char* src) { + strncpy(dest, src, N - 1); + dest[N - 1] = '\0'; +} + +// Every saved setting except the WiFi networks, which have their own NVS key. +struct SettingsValues { + uint32_t sleepTimeoutMin; + char hostname[SETTINGS_HOSTNAME_MAX_LEN + 1]; + uint8_t volume; + bool welcome; + bool serialLog; + uint32_t continuousTimeoutMin; + char loading[SETTINGS_LOADING_MAX_LEN + 1]; + char eyesStyle[SETTINGS_EYES_STYLE_MAX_LEN + 1]; + char accessToken[SETTINGS_ACCESS_TOKEN_MAX_LEN + 1]; + ServoRanges servoRanges; + char rgbOrder[SETTINGS_RGB_ORDER_MAX_LEN + 1]; + bool oledRotate180; + + static SettingsValues defaults(); + + // The patch must be valid (SettingsPatch::invalidField() == nullptr). + void apply(const SettingsPatch& patch); + + // Reads NVS over the current values; keys that are missing or invalid keep theirs. + void load(); + + // Writes every key. clearFirst also erases the rest of the namespace, WiFi networks included. + bool save(bool clearFirst = false) const; + + void log(const char* prefix) const; +}; + +extern SettingsValues g_settings; +extern WifiNetworkList g_wifiNetworks; +extern char g_bootHostname[SETTINGS_HOSTNAME_MAX_LEN + 1]; diff --git a/test/test_settings_validate/test_settings_validate.cpp b/test/test_settings_validate/test_settings_validate.cpp index 0d8d0c5b..7dbb3134 100644 --- a/test/test_settings_validate/test_settings_validate.cpp +++ b/test/test_settings_validate/test_settings_validate.cpp @@ -106,18 +106,30 @@ void test_wifi_password() { } void test_servo_ranges() { - const uint8_t validMin[SETTINGS_SERVO_COUNT] = {10, 20, 30, 40, 50}; - const uint8_t validMax[SETTINGS_SERVO_COUNT] = {20, 30, 40, 50, 60}; - const uint8_t equalMin[SETTINGS_SERVO_COUNT] = {10, 20, 30, 40, 50}; - const uint8_t equalMax[SETTINGS_SERVO_COUNT] = {10, 30, 40, 50, 60}; - const uint8_t overMin[SETTINGS_SERVO_COUNT] = {10, 20, 30, 40, 50}; - const uint8_t overMax[SETTINGS_SERVO_COUNT] = {20, 30, 40, 50, 181}; - - TEST_ASSERT_FALSE(settingsValidateServoRanges(nullptr, validMax)); - TEST_ASSERT_FALSE(settingsValidateServoRanges(validMin, nullptr)); - TEST_ASSERT_FALSE(settingsValidateServoRanges(equalMin, equalMax)); - TEST_ASSERT_FALSE(settingsValidateServoRanges(overMin, overMax)); - TEST_ASSERT_TRUE(settingsValidateServoRanges(validMin, validMax)); + const ServoRanges valid = {{10, 20, 30, 40, 50}, {20, 30, 40, 50, 180}}; + const ServoRanges equal = {{10, 20, 30, 40, 50}, {20, 30, 40, 50, 50}}; + const ServoRanges over = {{10, 20, 30, 40, 50}, {20, 30, 40, 50, 181}}; + + TEST_ASSERT_TRUE(valid.valid()); + TEST_ASSERT_FALSE(equal.valid()); + TEST_ASSERT_FALSE(over.valid()); +} + +void test_patch_names_the_first_invalid_field() { + SettingsPatch patch; + + TEST_ASSERT_NULL(patch.invalidField()); + + patch.volume = 50; + patch.hostname = "tiny-engineer"; + TEST_ASSERT_NULL(patch.invalidField()); + + patch.volume = 101; + patch.rgbOrder = "XYZ"; + TEST_ASSERT_EQUAL_STRING("volume", patch.invalidField()); + + patch.volume = 100; + TEST_ASSERT_EQUAL_STRING("rgb_order", patch.invalidField()); } void test_rgb_order() { @@ -145,6 +157,7 @@ int main() { RUN_TEST(test_wifi_ssid); RUN_TEST(test_wifi_password); RUN_TEST(test_servo_ranges); + RUN_TEST(test_patch_names_the_first_invalid_field); RUN_TEST(test_rgb_order); return UNITY_END(); } From 3d914216b0c8af5ff528b757725b7fe5607c2923 Mon Sep 17 00:00:00 2001 From: shodan Date: Sun, 11 Oct 2026 15:29:55 +0200 Subject: [PATCH 03/12] refactor(http): rewrite the web UI in TypeScript and Preact The control panel is Preact components in TypeScript instead of modules that look up elements by id in one large index.html. Every view, the auth and reboot gates and the setup wizard render the same markup with the same classes, so style.css only loses the rules that toggled visibility through body classes. - wouter-preact routes between pages in the browser; the firmware still serves the page on every view path, so deep links work. - StatusProvider owns the status bar and the busy lock. Button reads the lock, which replaces disabling every .btn by hand. - RobotProvider owns boot, the token check, health and settings. - Forms keep a draft that starts over when the robot returns new settings (useResettableState). - Tests render the app with Testing Library against the typed fake robot and query by role and label. - tsc runs in npm run lint; html-validate and html-minifier-terser go. Co-Authored-By: Claude Opus 5.5 (1M context) --- .cursor/rules/sync-api-endpoints.mdc | 12 +- .github/PULL_REQUEST_TEMPLATE.md | 2 +- AGENTS.md | 6 +- CONTRIBUTING.md | 2 +- docs/settings.md | 6 +- docs/testing.md | 4 +- scripts/embed_ui.py | 2 +- ui/.htmlvalidate.json | 3 - ui/eslint.config.js | 10 +- ui/index.html | 1058 +--------- ui/package-lock.json | 2785 +++++++++++++++++++++---- ui/package.json | 15 +- ui/src/App.tsx | 97 + ui/src/animations.js | 35 - ui/src/api.js | 70 - ui/src/api/client.ts | 65 + ui/src/api/types.ts | 58 + ui/src/config.js | 246 --- ui/src/config/ConfigPage.tsx | 342 +++ ui/src/config/HostnameField.tsx | 30 + ui/src/config/accessToken.tsx | 91 + ui/src/config/wifiNetworks.tsx | 207 ++ ui/src/device.js | 7 - ui/src/dom.js | 14 - ui/src/gates/gates.tsx | 72 + ui/src/health.js | 93 - ui/src/main.js | 7 - ui/src/main.tsx | 5 + ui/src/robot.tsx | 153 ++ ui/src/servo-ranges.js | 38 - ui/src/servo.js | 90 - ui/src/servos.ts | 24 + ui/src/settings.js | 25 - ui/src/setup/Wizard.tsx | 150 ++ ui/src/setup/calibration.js | 160 -- ui/src/setup/calibration.tsx | 200 ++ ui/src/setup/led.js | 119 -- ui/src/setup/led.tsx | 136 ++ ui/src/setup/steps.tsx | 181 ++ ui/src/setup/wizard.js | 298 --- ui/src/shell.js | 190 -- ui/src/status.js | 62 - ui/src/status/status.tsx | 112 + ui/src/style.css | 54 +- ui/src/tests.js | 16 - ui/src/useResettableState.ts | 15 + ui/src/views/Animations.tsx | 68 + ui/src/views/ApiReference.tsx | 605 ++++++ ui/src/views/Home.tsx | 129 ++ ui/src/views/ServoControl.tsx | 119 ++ ui/src/views/Tests.tsx | 34 + ui/src/wifi-networks.js | 152 -- ui/test/auth.test.js | 48 - ui/test/auth.test.tsx | 42 + ui/test/config.test.js | 74 - ui/test/config.test.tsx | 75 + ui/test/robot.js | 159 -- ui/test/robot.tsx | 173 ++ ui/test/servo.test.js | 39 - ui/test/servo.test.tsx | 43 + ui/test/wifi-networks.test.js | 140 -- ui/test/wifi-networks.test.tsx | 158 ++ ui/test/wizard.test.js | 152 -- ui/test/wizard.test.tsx | 165 ++ ui/tsconfig.json | 19 + ui/{vite.config.js => vite.config.ts} | 14 +- 66 files changed, 6041 insertions(+), 3734 deletions(-) delete mode 100644 ui/.htmlvalidate.json create mode 100644 ui/src/App.tsx delete mode 100644 ui/src/animations.js delete mode 100644 ui/src/api.js create mode 100644 ui/src/api/client.ts create mode 100644 ui/src/api/types.ts delete mode 100644 ui/src/config.js create mode 100644 ui/src/config/ConfigPage.tsx create mode 100644 ui/src/config/HostnameField.tsx create mode 100644 ui/src/config/accessToken.tsx create mode 100644 ui/src/config/wifiNetworks.tsx delete mode 100644 ui/src/device.js delete mode 100644 ui/src/dom.js create mode 100644 ui/src/gates/gates.tsx delete mode 100644 ui/src/health.js delete mode 100644 ui/src/main.js create mode 100644 ui/src/main.tsx create mode 100644 ui/src/robot.tsx delete mode 100644 ui/src/servo-ranges.js delete mode 100644 ui/src/servo.js create mode 100644 ui/src/servos.ts delete mode 100644 ui/src/settings.js create mode 100644 ui/src/setup/Wizard.tsx delete mode 100644 ui/src/setup/calibration.js create mode 100644 ui/src/setup/calibration.tsx delete mode 100644 ui/src/setup/led.js create mode 100644 ui/src/setup/led.tsx create mode 100644 ui/src/setup/steps.tsx delete mode 100644 ui/src/setup/wizard.js delete mode 100644 ui/src/shell.js delete mode 100644 ui/src/status.js create mode 100644 ui/src/status/status.tsx delete mode 100644 ui/src/tests.js create mode 100644 ui/src/useResettableState.ts create mode 100644 ui/src/views/Animations.tsx create mode 100644 ui/src/views/ApiReference.tsx create mode 100644 ui/src/views/Home.tsx create mode 100644 ui/src/views/ServoControl.tsx create mode 100644 ui/src/views/Tests.tsx delete mode 100644 ui/src/wifi-networks.js delete mode 100644 ui/test/auth.test.js create mode 100644 ui/test/auth.test.tsx delete mode 100644 ui/test/config.test.js create mode 100644 ui/test/config.test.tsx delete mode 100644 ui/test/robot.js create mode 100644 ui/test/robot.tsx delete mode 100644 ui/test/servo.test.js create mode 100644 ui/test/servo.test.tsx delete mode 100644 ui/test/wifi-networks.test.js create mode 100644 ui/test/wifi-networks.test.tsx delete mode 100644 ui/test/wizard.test.js create mode 100644 ui/test/wizard.test.tsx create mode 100644 ui/tsconfig.json rename ui/{vite.config.js => vite.config.ts} (65%) diff --git a/.cursor/rules/sync-api-endpoints.mdc b/.cursor/rules/sync-api-endpoints.mdc index 84d3d82f..74f23787 100644 --- a/.cursor/rules/sync-api-endpoints.mdc +++ b/.cursor/rules/sync-api-endpoints.mdc @@ -1,18 +1,18 @@ --- -description: When HTTP routes change, update the HTML index page and API docs together. +description: When HTTP routes change, update the web UI API page and API docs together. globs: src/http/**/* alwaysApply: false --- # Sync HTTP endpoints across firmware and docs -When adding, removing, or changing the method, path, or query parameters of any HTTP route, update all of the following in the same change: +When adding, removing, or changing the method, path, or parameters of any HTTP route, update all of the following in the same change: -1. **HTML index** — [`src/http/index_page.cpp`](src/http/index_page.cpp): main endpoint table matches all routes; parameter sections for `/anim`, `/settings`, and `/test/servo` list every supported value and range +1. **Web UI API page** — [`ui/src/views/ApiReference.tsx`](ui/src/views/ApiReference.tsx): main endpoint table matches all routes; parameter sections for `/anim`, `/settings`, and `/test/servo` list every supported value and range 2. **API reference** — [`docs/api.md`](docs/api.md): full section per endpoint (curl, JSON, params, errors) 3. **Summaries** — if the endpoint list, params, or boot URLs changed, also update [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md) -4. **405 paths** — if new `/test/*` or similar paths get wrong-method handling, update `isHttpTestPath()` in [`src/http/test_handlers.cpp`](src/http/test_handlers.cpp) and [`handleNotFound`](src/http/routes.cpp) as needed +4. **Route table** — add the route to `kRoutes` in [`src/http/routes.cpp`](src/http/routes.cpp) with its access rule. 404 and 405 answers come from that table -Do not duplicate long behavioral docs (animation behavior tables, RGB LED) in the HTML index — one-line route descriptions only, except the supported-parameter lists for `/anim`, `/settings`, and `/test/servo`. +Do not duplicate long behavioral docs (animation behavior tables, RGB LED) in the web UI API page — one-line route descriptions only, except the supported-parameter lists for `/anim`, `/settings`, and `/test/servo`. -Parameter values in the HTML index must match [`docs/api.md`](docs/api.md) exactly. +Parameter values in the web UI API page must match [`docs/api.md`](docs/api.md) exactly. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index aed8890e..babc5a71 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ - [ ] Breaking HTTP / pins / NVS / servo defaults / hook CLI called out as `type!:` plus a `BREAKING CHANGE:` footer - [ ] Firmware: `pio run` and/or `pio test -e native` when that code changed - [ ] Packages: `npm test --prefix packages/tiny-engineer-cli` / `…/tiny-engineer-cursor` / `…/tiny-engineer-antigravity` / `…/tiny-engineer-claude-code` when that package changed -- [ ] HTTP: HTML index (`ui/index.html`) + `docs/api.md` (and README / `docs/hardware/testing.md` if the route list changed) +- [ ] HTTP: web UI API page (`ui/src/views/ApiReference.tsx`) + `docs/api.md` (and README / `docs/hardware/testing.md` if the route list changed) - [ ] Settings: layers in `docs/settings.md`; no raw `access_token` in logs - [ ] CAD: `.f3d` + exported `.3mf`; CERN-OHL-S; `AiEmblem.3mf` not used as a branding swap - [ ] Pins: `include/pins.h` + `docs/hardware/` together diff --git a/AGENTS.md b/AGENTS.md index 89e0affe..e3fb8cc2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ Tiny Engineer is an open-source ESP32-C3 Wi-Fi desk robot: 3D-printed mechanics, | Path | Role | | --- | --- | | `src/`, `include/`, `lib/`, `data/` | Firmware (PlatformIO) | -| `ui/` | Web control panel (Vite + ES modules); `pio run` builds it into one minified page and embeds it gzipped in the firmware | +| `ui/` | Web control panel (Vite, TypeScript, Preact); `pio run` builds it into one minified page and embeds it gzipped in the firmware | | `packages/` | HTTP / hook CLIs (Cursor, Antigravity, Claude Code, …) — scope `integrations` | | `skills/` | Agent skills shared by Claude Code, Cursor and Antigravity — scope `integrations` | | `3d_models/` | CAD and printables (CERN-OHL-S) | @@ -55,12 +55,12 @@ Significant: logic, types, APIs, headers, firmware under `src/` / `include/` / ` When adding, removing, or changing method, path, or query params of any HTTP route, update in the **same** change: -1. **HTML index** — [`ui/index.html`](ui/index.html): endpoint table and param lists for `/anim`, `/settings`, `/test/servo` +1. **Web UI API page** — [`ui/src/views/ApiReference.tsx`](ui/src/views/ApiReference.tsx): endpoint table and param lists for `/anim`, `/settings`, `/test/servo` 2. **API reference** — [`docs/api.md`](docs/api.md) 3. **Summaries** — if the endpoint list, params, or boot URLs changed: [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md) 4. **Route table** — add the route to `kRoutes` in [`src/http/routes.cpp`](src/http/routes.cpp) with its access rule. 404 and 405 answers come from that table -HTML index: one-line route descriptions only (except supported-parameter lists). Param values must match `docs/api.md` exactly. +Web UI API page: one-line route descriptions only (except supported-parameter lists). Param values must match `docs/api.md` exactly. ## Commits diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0022119f..bec3b0ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ python3 scripts/check_pcb.py **Firmware.** `pio run` plus native tests when you touch settings validation. Pin changes update [`include/pins.h`](include/pins.h) **and** [`docs/hardware/`](docs/hardware/) in the same PR. -**HTTP / API.** Same PR must update the HTML index ([`ui/index.html`](ui/index.html)) and [`docs/api.md`](docs/api.md). If the route list, params, or boot URLs changed, also update [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md). See [.cursor/rules/sync-api-endpoints.mdc](.cursor/rules/sync-api-endpoints.mdc). +**HTTP / API.** Same PR must update the web UI API page ([`ui/src/views/ApiReference.tsx`](ui/src/views/ApiReference.tsx)) and [`docs/api.md`](docs/api.md). If the route list, params, or boot URLs changed, also update [`README.md`](README.md) and [`docs/hardware/testing.md`](docs/hardware/testing.md). See [.cursor/rules/sync-api-endpoints.mdc](.cursor/rules/sync-api-endpoints.mdc). **Settings.** Follow the layer checklist in [docs/settings.md](docs/settings.md). Never log the raw `access_token` (set/unset only). diff --git a/docs/settings.md b/docs/settings.md index 3a7e98d4..a98fa7ad 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -49,9 +49,9 @@ Call the getter wherever the value affects behavior (e.g. `settingsVolume()` in ### 4. Web UI — [`ui/`](../ui/) -1. Config form control (`#config-foo`). Use `.range-row` + range/number for 0–N scales (see volume). -2. `loadSettings()` reads `j.foo`; save POST includes `&foo=…`. -3. API reference table under POST `/settings`: param, type, range. +1. Add the field to `Settings` in [`ui/src/api/types.ts`](../ui/src/api/types.ts). +2. Add a control to `ConfigForm` in [`ui/src/config/ConfigPage.tsx`](../ui/src/config/ConfigPage.tsx): read it in `draftFrom()` and send it from `save()`. Use `.range-row` + range/number for 0–N scales (see volume). +3. API reference table under POST `/settings` in [`ui/src/views/ApiReference.tsx`](../ui/src/views/ApiReference.tsx): param, type, range. 4. Home Config card blurb if the setting is user-facing. ### 5. Docs (required with the same change) diff --git a/docs/testing.md b/docs/testing.md index a35aa7e1..cf113fe8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -28,7 +28,7 @@ The `expression-demo` environment builds a separate OLED-only application withou ## Web UI -The control panel in [`ui/`](../ui/) is linted with [html-validate](https://html-validate.org/), ESLint, [Stylelint](https://stylelint.io/) (`stylelint-config-standard`) and Prettier. Node 20.19+ on 20.x, or 22.12+. +The control panel in [`ui/`](../ui/) (TypeScript and Preact) is type-checked with `tsc` and linted with ESLint, [Stylelint](https://stylelint.io/) (`stylelint-config-standard`) and Prettier. Node 20.19+ on 20.x, or 22.12+. ```bash npm ci --prefix ui @@ -37,7 +37,7 @@ npm test --prefix ui npm run format --prefix ui # apply Prettier ``` -`npm test` runs [Vitest](https://vitest.dev/) in jsdom: each test loads the real `index.html`, imports the UI modules, and drives them against a fake robot that stubs `fetch` and records requests ([`ui/test/robot.js`](../ui/test/robot.js)). The tests cover the auth gate, config saving and access-token handling, the servo page range hint, and the setup wizard (step order, what each step saves, step-only calibration moves, LED order validation). +`npm test` runs [Vitest](https://vitest.dev/) in jsdom: each test renders the app with [Testing Library](https://testing-library.com/docs/preact-testing-library/intro/) and drives it against a fake robot that stubs `fetch` and records requests ([`ui/test/robot.tsx`](../ui/test/robot.tsx)). The tests cover the auth gate, config saving and access-token handling, the staged WiFi network list, the servo page range hint, and the setup wizard (step order, what each step saves, step-only calibration moves, LED order validation). ## Packages diff --git a/scripts/embed_ui.py b/scripts/embed_ui.py index 15aa49c3..50e112c6 100644 --- a/scripts/embed_ui.py +++ b/scripts/embed_ui.py @@ -19,7 +19,7 @@ def npm(*args): def newest_source_mtime(): - sources = [ui_dir / "index.html", ui_dir / "vite.config.js", ui_dir / "package.json", ui_dir / "package-lock.json"] + sources = [ui_dir / name for name in ("index.html", "vite.config.ts", "tsconfig.json", "package.json", "package-lock.json")] sources += [path for path in (ui_dir / "src").rglob("*") if path.is_file()] return max(path.stat().st_mtime for path in sources) diff --git a/ui/.htmlvalidate.json b/ui/.htmlvalidate.json deleted file mode 100644 index b94358ab..00000000 --- a/ui/.htmlvalidate.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["html-validate:recommended", "html-validate:prettier"] -} diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 138cf723..df017544 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -1,6 +1,8 @@ import js from "@eslint/js"; import stylistic from "@stylistic/eslint-plugin"; +import reactHooks from "eslint-plugin-react-hooks"; import globals from "globals"; +import tseslint from "typescript-eslint"; const controlFlow = [ "if", @@ -15,9 +17,11 @@ const controlFlow = [ "continue", ]; -export default [ +export default tseslint.config( { ignores: ["dist/"] }, js.configs.recommended, + tseslint.configs.strict, + reactHooks.configs.flat.recommended, { plugins: { "@stylistic": stylistic }, languageOptions: { globals: globals.browser }, @@ -39,7 +43,7 @@ export default [ }, }, { - files: ["*.config.js"], + files: ["*.config.*"], languageOptions: { globals: globals.node }, }, -]; +); diff --git a/ui/index.html b/ui/index.html index 461c6ff3..ed4134f3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,1064 +4,8 @@ Tiny Engineer - -
-
-

Access token required

-

Enter the device access token to use the control panel.

-
-
- - -
-
Invalid access token.
- -
-
-
-
-
-

Factory reset done

-

- Settings and WiFi credentials are cleared. Servo ranges, RGB LED mapping, and screen - rotation stay. Power-cycle or press the device reset button. -

-

- Then join the robot WiFi shown on the OLED and open the setup page to configure home WiFi. - You can retune servo ranges, screen rotation, and LED mapping there. -

-
-
- - -
- -
-
-

Tiny Engineer

-

A desk robot that acts out your AI coding assistant while it works.

-
-
Loading status…
- -
- -
-

API reference

-

- JSON HTTP API on port 80. Optional Bearer auth when an access token is configured. Test - routes are POST only. GET /auth is always public. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MethodPathDescription
GET/Web control panel
GET/animationsAnimations page
GET/testsHardware tests page
GET/servoServo control page
GET/configConfig page
GET/apiThis API reference
GET/authWhether API auth is required (always public)
GET/healthHealth JSON, no side effects
GET/settingsPersistent settings
POST/settingsUpdate settings (see parameters below)
POST/settings/resetFactory reset all settings to defaults
PUT/settings/wifiReplace the saved WiFi networks (JSON body)
GET/animCurrent animation name
POST/animSet animation (see parameters below)
POST/playStream a WAV while an animation runs (see parameters below)
POST/test/audioPlay tone test
POST/test/audio/bellPlay bell WAV from LittleFS
POST/test/screenOLED demo
POST/test/movementAll servos exercise
POST/test/ledRGB LED cycle
POST/test/servoMove one servo (see parameters below)
POST/setup/servoSetup AP only: slow 0–180° move (see parameters below)
POST/setup/ledSetup AP only: light logical RGB or one WS2812 wire byte (see parameters below)
POST/setup/audioSetup AP only: play welcome.wav from LittleFS
POST/setup/oledSetup AP only: preview OLED 180° rotation (see parameters below)
-

POST /settings — query params (at least one required):

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParamTypeRange
sleep_timeoutinteger1–1440 minutes (positive)
hostnamestring1–31 chars, letters/digits/hyphen
volumeinteger0–100 percent (speaker gain)
welcomeinteger0 or 1 (boot welcome animation)
serial_loginteger0 or 1 (USB serial debug logging)
continuous_timeoutinteger1–1440 minutes
loadingstringprogress or sleep_inertia (boot screen; next reboot)
eyes_stylestring - classic, kaomoji, cover, or - dots (OLED eyes look; immediate) -
access_tokenstring0–64 printable ASCII; empty clears (disables auth)
servo_minsstring - 5 comma-separated ints 0–180; setup AP only; requires - servo_maxs -
servo_maxsstring5 comma-separated ints 0–180; setup AP only; each max > min
rgb_orderstring - RGB, RBG, GRB, GBR, - BRG, or BGR; setup AP only; default GRB -
oled_rotate_180integer - 0 or 1; setup AP only; default - 0 -
-

- Servo min/max, RGB LED mapping, and OLED rotation can only be set in setup AP mode. - PUT /settings/wifi takes a JSON array of 1–5 - {"ssid", "password"} objects; leave out password to keep a saved - one. In setup AP mode the robot tests the first network before saving. During setup - (credentials not saved), control APIs return 503 - wifi not configured; POST /setup/servo, - POST /setup/led, POST /setup/audio, and - POST /setup/oled stay available. Servo ranges, RGB mapping, and screen rotation - survive factory reset and can be retuned in the setup wizard. -

-

POST /anim — query param name:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueDescription
noneIdle pose
typingTyping gesture
readingReading gesture
thinkingThinking gesture
talkingSpeaking gesture; soft torso shifts (audio-reactive during /play)
ringOne-shot bell gesture
welcomeOne-shot hello gesture
attentionInput-request gesture + audio
errorObstacle gesture + audio
abortAbort gesture + audio
deadOut-of-power: dead.wav, then X X
wakeupOne-shot sleep-inertia wake
sleepClose eyes and sleep
-

- POST /play or /play/{name} — body: 16-bit mono PCM WAV at - 22050 Hz. name runs while the clip plays: -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ValueDescription
talkingDefault; motion follows speech energy
typingTyping gesture
readingReading gesture
thinkingThinking gesture
noneIdle pose
-

POST /test/servo — query params:

- - - - - - - - - - - - - - - - - - - - -
ParamTypeRange
indexinteger0–4
anglenumber0–180
-

- POST /setup/servo — setup AP only, ~25°/s, no safe-range clamp. Query - params: -

- - - - - - - - - - - - - - - - - - - - - - - - - -
ParamTypeRange
allinteger90 (move every joint to 90°)
indexinteger0–4 (required with angle when all is omitted)
anglenumber0–180
-

- POST /setup/led — setup AP only, no Wi-Fi gate. Lights a logical color or - one WS2812 wire byte. Query params: -

- - - - - - - - - - - - - - - - - - - - - - - - - -
ParamTypeRange
colorstring - R, G, or B (logical; uses saved or - rgb_order) -
rgb_orderstring - RGB, RBG, GRB, GBR, - BRG, or BGR (optional with color) -
bytestring - 0, 1, 2, or off (omit or - off releases the hold) -
-

- POST /setup/audio — setup AP only, no Wi-Fi gate. Plays - welcome.wav from LittleFS. No query params. -

-

- POST /setup/oled — setup AP only, no Wi-Fi gate. Preview OLED rotation - and draw THIS WAY UP. Omit rotate_180 to restore the provisioning - screen. Query params: -

- - - - - - - - - - - - - - - -
ParamTypeRange
rotate_180integer0 or 1 (omit to restore provisioning text)
-
- -
-

Animations

-

- Set the robot’s current gesture. Continuous animations keep going until changed or - timed out. -

-
Current: …
-
- - - - - - - - - - - - - -
-
- -
-

Servo control

-

Move a single servo to a target angle.

-
-
- - -

Safe range: 60–130°

-
-
- -
-
-
-
-
-
- -
-
- 0°90°180° -
-
- -
-
-
- - -
-
-
- -
-

Hardware tests

-

Run built-in diagnostics. Each test blocks until complete.

-
-

Play ascending tones through the speaker.

- -
-
-

Play the bell WAV file from storage.

- -
-
-

Draw a demo pattern on the OLED screen.

- -
-
-

Exercise all five servos through a sweep.

- -
-
-

Cycle the onboard RGB LED through colors.

- -
-
- -
-

Config

-

Saved to flash. Most changes apply right away.

-
-

Tiny Engineer setup

-

Step 1 of 5 · Servos

-
-
-
-
-
-
-

Attach the printed parts

-

- All joints use the full 0–180° electrical scale. - 90° is shaft center, not the middle of the safe band. -

-

- Press each printed part onto the servo shaft at rest: head toward the laptop, neck - straight, hands down, body centered in the chair. Snug the shaft screws. -

- - -
-
- -
- - - - - -
-
-
-
-

Network

- After reboot -
-
- - -

Letters, digits, hyphen — no .local

-
-
-
-
-

WiFi networks

- After reboot -
-

- At boot the robot joins the strongest saved network in range. Up to 5 networks. Changes - are sent on Save. The robot cannot test a network from here, so check the password. -

-
    -
    - - -
    -
    - - -

    Leave empty only for open networks.

    -
    - -
    -
    -
    -

    Timeouts

    - Immediate -
    -
    - - -

    Minutes in animation none before OLED blanks

    -
    -
    - - -

    Max time for typing / reading / thinking / talking

    -
    -
    -
    -
    -

    Audio

    - Immediate -
    -
    - -
    - - -
    -
    -
    -
    -

    Display

    -
    -
    - Immediate -
    - -

    - Classic is the default procedural eyes. Kaomoji maps each animation to a face from the - expression library. Cover fills each half of the screen for a mask with eye holes; - animations change height with black margins. Dots draws a 12x12 circle per eye; size - and position follow the same poses as classic. Add new styles in firmware under - display/eyes/styles/. -

    -
    -
    -
    -

    Boot behavior

    -

    Loading screen changes take effect on next boot.

    -
    -
    - After reboot -
    - - -
    -
    -
    - Welcome on bootImmediate -
    -
    - - -
    -
    -
    -
    - Serial loggingImmediate -
    -
    - - -
    -
    -
    -
    -

    Security

    -
    - -
    - - -
    -

    Auth disabled

    -
    -
    - -
    -
    -

    Factory reset

    -

    - Erases saved settings and restores defaults. WiFi credentials are cleared. Servo ranges, - RGB LED mapping, and screen rotation stay. Power-cycle the device to reopen setup AP mode - and configure WiFi again. -

    - -
    -
    - - - - + diff --git a/ui/package-lock.json b/ui/package-lock.json index 437cd9f5..a70f22b0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5,17 +5,26 @@ "packages": { "": { "name": "tiny-engineer-ui", + "dependencies": { + "preact": "~10.29.8", + "wouter-preact": "^3.13.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", + "@preact/preset-vite": "^2.10.6", "@stylistic/eslint-plugin": "^5.10.0", + "@testing-library/preact": "^3.2.4", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^24.19.2", "eslint": "^10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.13.0", - "html-minifier-terser": "^7.2.0", - "html-validate": "^11.16.2", "jsdom": "^30.1.2", "prettier": "^3.9.9", "stylelint": "^17.16.0", "stylelint-config-standard": "^40.0.0", + "typescript": "~6.0", + "typescript-eslint": "^8.71.1", "vite": "^8.3.3", "vite-plugin-singlefile": "^2.3.3", "vitest": "^5.0.3" @@ -72,6 +81,166 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@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" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", @@ -82,6 +251,156 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", @@ -475,16 +794,6 @@ } } }, - "node_modules/@html-validate/stylish": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@html-validate/stylish/-/stylish-6.1.0.tgz", - "integrity": "sha512-ceNvF6/5hgvw/OrxXtdl2KurRVLN/Q/OkD/qminRRY/IIERmVaqJOpDF1nhRZMrOG/vyKKW0+Nxw6lSoHUP4Ow==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.16.0 || >= 24.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -562,6 +871,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -572,17 +892,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -673,68 +982,180 @@ "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", - "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", - "cpu": [ - "arm" - ], + "node_modules/@preact/preset-vite": { + "version": "2.10.6", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.6.tgz", + "integrity": "sha512-ZZ5RIT2ZVXg8UxRA8VZpoT8CdpDG7RFIqOT4bELqNBp85+HKvQBbgmh3gsoW1UOQXx26TLe+ZRNKI7q281Kckw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@prefresh/vite": "^2.4.11", + "@rollup/pluginutils": "^5.0.0", + "babel-plugin-transform-hook-names": "^1.0.2", + "debug": "^4.4.3", + "magic-string": "^0.30.21", + "picocolors": "^1.1.1", + "vite-prerender-plugin": "^0.5.8", + "zimmerframe": "^1.1.4" + }, + "peerDependencies": { + "@babel/core": "7.x", + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", - "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", - "cpu": [ - "arm64" - ], + "node_modules/@preact/preset-vite/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", - "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", - "cpu": [ - "arm64" - ], + "node_modules/@prefresh/babel-plugin": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.4.tgz", + "integrity": "sha512-GSwqHGVem7AjnOKAkmLNbAExqZZFddf9S3ySOwowGEsJR8vwNKJ7Z/262fov5wra8LkDVSmFw3CjMGRdLoCmRA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", - "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", - "cpu": [ - "x64" - ], + "node_modules/@prefresh/core": { + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.11.tgz", + "integrity": "sha512-Ml00PP8jeHaADxfQqxUs4+M5JS+T5J26UnvPVfsrmlpGw2I3e9XtM0DMeuzkmvg+Tn7VdHSMpLBrDPG0iHoDqQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, + "node_modules/@prefresh/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/vite": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", + "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.22.1", + "@prefresh/babel-plugin": "^0.5.2", + "@prefresh/core": "^1.5.0", + "@prefresh/utils": "^1.2.0", + "@rollup/pluginutils": "^4.2.1" + }, + "peerDependencies": { + "preact": "^10.4.0 || ^11.0.0-0", + "vite": ">=2.0.0" + } + }, + "node_modules/@prefresh/vite/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/@prefresh/vite/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/@prefresh/vite/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ "darwin" ], "engines": { @@ -953,6 +1374,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "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/@rollup/pluginutils/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/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -1018,6 +1469,94 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/preact": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.2.4.tgz", + "integrity": "sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^8.11.1" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" + } + }, + "node_modules/@testing-library/preact/node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/preact/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1048,21 +1587,260 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@typescript-eslint/utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/@typescript-eslint/types": { + "node_modules/@typescript-eslint/visitor-keys": { "version": "8.71.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", - "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1185,6 +1963,34 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1205,6 +2011,32 @@ "node": ">=8" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-transform-hook-names": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", + "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.12.10" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1215,6 +2047,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bidi-js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", @@ -1225,6 +2070,13 @@ "require-from-string": "^2.0.2" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -1251,12 +2103,39 @@ "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==", + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } }, "node_modules/cacheable": { "version": "2.5.0", @@ -1272,6 +2151,56 @@ "qified": "^0.10.1" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1282,16 +2211,26 @@ "node": ">=6" } }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "dev": true, - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, "node_modules/chai": { "version": "6.3.0", @@ -1303,17 +2242,44 @@ "node": ">=18" } }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "source-map": "~0.6.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 10.0" + "node": ">=8" } }, "node_modules/color-convert": { @@ -1343,15 +2309,12 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "9.0.2", @@ -1405,6 +2368,23 @@ "node": ">=12" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1419,6 +2399,19 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1471,6 +2464,39 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1478,6 +2504,53 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1488,17 +2561,94 @@ "node": ">=8" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1539,6 +2689,47 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1546,6 +2737,29 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1618,6 +2832,26 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -1899,6 +3133,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1914,6 +3164,36 @@ "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/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-east-asian-width": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", @@ -1927,6 +3207,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2033,6 +3352,32 @@ "dev": true, "license": "MIT" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", @@ -2046,6 +3391,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2059,6 +3446,46 @@ "node": ">=20" } }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -2079,28 +3506,6 @@ "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/html-minifier-terser": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", - "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "~5.3.2", - "commander": "^10.0.0", - "entities": "^4.4.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.15.1" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": "^14.13.1 || >=16.0.0" - } - }, "node_modules/html-tags": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-5.1.0.tgz", @@ -2114,156 +3519,181 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-validate": { - "version": "11.16.2", - "resolved": "https://registry.npmjs.org/html-validate/-/html-validate-11.16.2.tgz", - "integrity": "sha512-oci8F4t1AQdAwkOK895hOOrMTSY0zZ6fk4p2LCvGKQDjL/CYDTGyIoVTzZWII0DfwA7gpq7+qJxAKubUofgo1Q==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/html-validate" - } - ], "license": "MIT", "dependencies": { - "@html-validate/stylish": "^6.0.0", - "@sidvind/better-ajv-errors": "7.0.0", - "ajv": "^8.0.0", - "kleur": "^4.1.0", - "prompts": "^2.0.0", - "semver": "^7.0.0" - }, - "bin": { - "html-validate": "bin/html-validate.mjs" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": "^22.22.0 || >= 24.8.0" - }, - "peerDependencies": { - "@jest/globals": "^29.0.3 || ^30.0.0", - "@vitest/expect": "^3.2.0 || ^4.0.1 || ^5.0.0", - "jest": "^29.0.3 || ^30.0.0", - "jest-snapshot": "^29.0.3 || ^30.0.0", - "vitest": "^3.2.0 || ^4.0.1 || ^5.0.0" + "node": ">=6" }, - "peerDependenciesMeta": { - "@jest/globals": { - "optional": true - }, - "@vitest/expect": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-snapshot": { - "optional": true - }, - "vitest": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-validate/node_modules/@sidvind/better-ajv-errors": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@sidvind/better-ajv-errors/-/better-ajv-errors-7.0.0.tgz", - "integrity": "sha512-imVsp5D3KxJ8+uUmu2dsLKnFg3qdX952v/L1gZoCa0NO2ivhQWHMpYM2KmpFrRXHWFjlqkNZ/935nxiTqXEi1A==", + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^22.12 || >= 24.0" + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, - "peerDependencies": { - "ajv": "^8.0.0" + "engines": { + "node": ">= 0.4" } }, - "node_modules/html-validate/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/html-validate/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, "license": "MIT" }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, "engines": { - "node": ">= 4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=6" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">=0.8.19" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2297,6 +3727,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2307,6 +3750,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", @@ -2327,6 +3787,126 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2404,6 +3984,19 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -2425,6 +4018,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -2445,15 +4051,12 @@ "node": ">=0.10.0" } }, - "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==", + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", @@ -2772,16 +4375,6 @@ "dev": true, "license": "MIT" }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -2792,6 +4385,16 @@ "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", @@ -2802,6 +4405,16 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mathml-tag-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-4.0.0.tgz", @@ -2919,15 +4532,25 @@ "dev": true, "license": "MIT" }, - "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==", + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", "dev": true, "license": "MIT", "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" } }, "node_modules/normalize-path": { @@ -2940,6 +4563,80 @@ "node": ">=0.10.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -3004,17 +4701,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3073,17 +4759,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3124,6 +4799,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -3201,6 +4886,24 @@ "dev": true, "license": "MIT" }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3227,28 +4930,42 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { - "node": ">= 6" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/prompts/node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/punycode": { @@ -3302,14 +5019,41 @@ ], "license": "MIT" }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">=8" } }, "node_modules/require-from-string": { @@ -3401,6 +5145,24 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3415,16 +5177,47 @@ } }, "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/shebang-command": { @@ -3450,6 +5243,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3463,12 +5332,15 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "node_modules/simple-code-frame": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", + "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "kolorist": "^1.6.0" + } }, "node_modules/slash": { "version": "5.1.0", @@ -3502,13 +5374,13 @@ } }, "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==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", "engines": { - "node": ">=0.10.0" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -3521,15 +5393,14 @@ "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==", + "node_modules/stack-trace": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", + "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", "dev": true, "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/std-env": { @@ -3539,6 +5410,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-width": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", @@ -3806,32 +5691,6 @@ "node": ">=8" } }, - "node_modules/terser": { - "version": "5.51.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", - "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/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/tinybench": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", @@ -3928,12 +5787,18 @@ "node": "^22.14.0 || >=24.0.0" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "0BSD" + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } }, "node_modules/type-check": { "version": "0.4.0", @@ -3948,6 +5813,44 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", @@ -3958,6 +5861,13 @@ "node": ">=22.19.0" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", @@ -3971,6 +5881,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -4088,6 +6029,34 @@ } } }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.14.tgz", + "integrity": "sha512-X2w9AS1OhwEUec9HSryGAmEnEQJGhW6eFoiCVJv8BfOTVpM8SOjk75cEw/IC28APDHZudeIRbP8Sp7wfABq+Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kolorist": "^1.8.0", + "magic-string": "0.x >= 0.26.0", + "node-html-parser": "^6.1.12", + "simple-code-frame": "^1.3.0", + "source-map": "^0.7.4", + "stack-trace": "^1.0.0-pre2" + }, + "peerDependencies": { + "vite": "5.x || 6.x || 7.x || 8.x" + } + }, + "node_modules/vite-prerender-plugin/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/vitest": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", @@ -4235,6 +6204,67 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", @@ -4258,6 +6288,18 @@ "node": ">=0.10.0" } }, + "node_modules/wouter-preact": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/wouter-preact/-/wouter-preact-3.13.0.tgz", + "integrity": "sha512-fRDI34kdE20xE6MEyAJ9J8wa5sNmVnBGg8SLzHq0bZFlewRKxh/OoXMiLyX+xqlSfqZmUzYfsOvSIppPeBictg==", + "license": "Unlicense", + "dependencies": { + "regexparam": "^3.0.0" + }, + "peerDependencies": { + "preact": "^10.0.0 || ^11.0.0-0" + } + }, "node_modules/write-file-atomic": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", @@ -4288,6 +6330,13 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4300,6 +6349,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/ui/package.json b/ui/package.json index 20b640d2..c1acff8b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,22 +9,31 @@ "dev": "vite", "build": "vite build", "test": "vitest run", - "lint": "html-validate index.html && eslint . && stylelint \"src/**/*.css\" && prettier --check .", + "lint": "tsc && eslint . && stylelint \"src/**/*.css\" && prettier --check .", "format": "prettier --write ." }, "devDependencies": { "@eslint/js": "^10.0.1", + "@preact/preset-vite": "^2.10.6", "@stylistic/eslint-plugin": "^5.10.0", + "@testing-library/preact": "^3.2.4", + "@testing-library/user-event": "^14.6.7", + "@types/node": "^24.19.2", "eslint": "^10.12.0", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.13.0", - "html-minifier-terser": "^7.2.0", - "html-validate": "^11.16.2", "jsdom": "^30.1.2", "prettier": "^3.9.9", "stylelint": "^17.16.0", "stylelint-config-standard": "^40.0.0", + "typescript": "~6.0", + "typescript-eslint": "^8.71.1", "vite": "^8.3.3", "vite-plugin-singlefile": "^2.3.3", "vitest": "^5.0.3" + }, + "dependencies": { + "preact": "~10.29.8", + "wouter-preact": "^3.13.0" } } diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 00000000..52063b67 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,97 @@ +import { Link, Route, Switch, useLocation } from "wouter-preact"; +import { AuthGate, RebootGate } from "./gates/gates"; +import { RobotProvider, useRobot } from "./robot"; +import { StatusBar, StatusProvider } from "./status/status"; +import { Animations } from "./views/Animations"; +import { ApiReference } from "./views/ApiReference"; +import { ConfigPage } from "./config/ConfigPage"; +import { Home } from "./views/Home"; +import { ServoControl } from "./views/ServoControl"; +import { Tests } from "./views/Tests"; + +const NAV = [ + ["/", "Home"], + ["/animations", "Animations"], + ["/tests", "Tests"], + ["/servo", "Servo"], + ["/config", "Config"], + ["/api", "API"], +] as const; + +function Nav() { + const [location] = useLocation(); + + return ( + + ); +} + +function Footer() { + const { version } = useRobot(); + + return ( + + ); +} + +function Shell() { + const { gate, inSetup } = useRobot(); + + if (gate === "booting") { + return null; + } + + if (gate === "auth") { + return ; + } + + if (gate === "reboot") { + return ; + } + + if (inSetup) { + return ( + <> + + + + ); + } + + return ( + <> +