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 ae145fa8..ad3ff7ba 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. **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. +Web UI API page: one-line route descriptions only (except supported-parameter lists). Param values must match `docs/api.md` exactly. ## Commits @@ -76,6 +76,8 @@ Breaking rules, SemVer mapping, and examples: [CONTRIBUTING.md](CONTRIBUTING.md) - **Firmware** — `pio run`; also `pio test -e native` when touching settings validation. Pin changes update `include/pins.h` **and** `docs/hardware/` together. - **Settings** — layer checklist in [docs/settings.md](docs/settings.md). Never log raw `access_token`. +- **HTTP routes** — add the route to `kRoutes` in `src/http/routes.cpp` with its `Access` level; `Refusal::check` enforces it. Routes with fields read a JSON body through `readJsonBody`; only `POST /anim` (`name`) and `/play` take other input, because the agent packages depend on them. +- **Web UI** — Preact + TypeScript in `ui/src`, plain `preact` / `preact/hooks` imports (no compat). One component per file; shared components in `ui/src/components`, helpers in `ui/src/lib`. `npm run lint --prefix ui` runs `tsc` and type-checked ESLint, which forbids `.then` / `.catch` chains (use try/catch). Keep markup and class names when refactoring: the CSS styles them by class and id. - **Integrations** — add/extend package tests; prefer short timeouts and ignore network errors so a missing robot does not stall the agent. - **CAD** — edit `.f3d` **and** export affected `3mf`. CERN-OHL-S. Do not swap `AiEmblem.3mf` as branding. New parts: [docs/3d/adding-parts.md](docs/3d/adding-parts.md). - **Mods** — optional add-ons under `mods//`. Models live in `3d_models/{cad,parts}/`. Commit `type(mods)` and name the mod in the summary. `feat(mods)` / `fix(mods)` do not version stock CAD. See [mods/README.md](mods/README.md). 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/api.md b/docs/api.md index 3ffc8f7e..0413a1a1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,10 +1,10 @@ # 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). -Optional auth: when an `access_token` is configured in settings, all JSON API routes require `Authorization: Bearer `. Empty token (default) means no auth. `GET /auth` is always public and reports whether auth is required. Missing/wrong token → **401** `{"ok":false,"error":"unauthorized"}`. HTML panel routes stay public (the UI prompts for the token). `Content-Type: application/json`. CORS: `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Authorization`. +Optional auth: when an `access_token` is configured in settings, all JSON API routes require `Authorization: Bearer `. Empty token (default) means no auth. `GET /auth` is always public and reports whether auth is required. Missing/wrong token → **401** `{"ok":false,"error":"unauthorized"}`. HTML panel routes stay public (the UI prompts for the token). Replies are `Content-Type: application/json`. Routes that take parameters (except `POST /anim` and `/play`) read a JSON object body sent with `Content-Type: application/json`; query parameters or form fields on those routes → **400** `send a JSON body with Content-Type: application/json`, and a body that is not a JSON object → **400** `body must be a JSON object`. CORS: requests with an `Origin` header get `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Headers: Authorization, Content-Type` and `Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS`; a preflight `OPTIONS` gets **200**. When WiFi credentials are not saved yet, control APIs (`/anim`, `/test/*`) return **503** `{"ok":false,"error":"wifi not configured"}`. Setup routes (`/`, `/config`, `/auth`, `/health`, `/settings`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`) stay available on the setup AP. @@ -35,12 +35,13 @@ curl http://tiny-engineer.local/auth ``` ```json -{ "ok": true, "required": false, "wifi_configured": true, "provisioning": false } +{ "ok": true, "version": "v0.3.0", "required": false, "wifi_configured": true, "provisioning": false } ``` | Field | Meaning | | --- | --- | | `ok` | Always `true` on this route | +| `version` | Firmware version, same as `GET /health` | | `required` | `true` when a non-empty access token is configured | | `wifi_configured` | `true` when WiFi credentials are saved in NVS | | `provisioning` | `true` when the setup AP is active | @@ -140,26 +141,22 @@ curl http://tiny-engineer.local/settings | `servo_maxs` | Per-joint safe maximum degrees (stock defaults **130, 130, 135, 125, 130**). Each max must be greater than the matching min | | `rgb_order` | Onboard WS2812 wire-byte order (`RGB`, `RBG`, `GRB`, `GBR`, `BRG`, `BGR`). Default **`GRB`**. Survives factory reset | | `oled_rotate_180` | OLED 180° rotation (`true` → `setRotation(2)`). Default **`false`**. Setup AP only. Survives factory reset | +| `reboot_required` | `true` once the saved `hostname`, `loading` or WiFi networks differ from what this boot started with; those apply only after a reboot | ### `POST /settings` -Update one or more settings. Query params. Values are written to NVS. `sleep_timeout`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `eyes_style`, and `access_token` apply immediately; a changed `hostname` or `loading` takes effect on the **next reboot**. WiFi networks are set with [`PUT /settings/wifi`](#put-settingswifi). Servo ranges (`servo_mins` + `servo_maxs`) are also **setup AP only**; they apply immediately to motion clamps. Outside setup AP → **400** `servo setup only in AP mode`. RGB LED mapping (`rgb_order`) is **setup AP only** and applies immediately to the onboard LED. Outside setup AP → **400** `rgb setup only in AP mode`. OLED rotation (`oled_rotate_180`) is **setup AP only** and applies immediately to the display. Outside setup AP → **400** `oled setup only in AP mode`. Requires Bearer when auth is enabled. +Update one or more settings from a JSON object body. Values are written to NVS. `sleep_timeout`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `eyes_style`, and `access_token` apply immediately; a changed `hostname` or `loading` takes effect on the **next reboot**. WiFi networks are set with [`PUT /settings/wifi`](#put-settingswifi). Servo ranges (`servo_mins` + `servo_maxs`), RGB LED mapping (`rgb_order`) and OLED rotation (`oled_rotate_180`) are **setup AP only** and apply immediately. Requires Bearer when auth is enabled. ```bash -curl -X POST "http://tiny-engineer.local/settings?sleep_timeout=2" -curl -X POST "http://tiny-engineer.local/settings?hostname=desk-bot" -curl -X POST "http://tiny-engineer.local/settings?volume=40" -curl -X POST "http://tiny-engineer.local/settings?welcome=0" -curl -X POST "http://tiny-engineer.local/settings?serial_log=1" -curl -X POST "http://tiny-engineer.local/settings?continuous_timeout=10" -curl -X POST "http://tiny-engineer.local/settings?loading=sleep_inertia" -curl -X POST "http://tiny-engineer.local/settings?eyes_style=kaomoji" -curl -X POST "http://tiny-engineer.local/settings?access_token=secret" -curl -X POST "http://tiny-engineer.local/settings?access_token=" -curl -X POST "http://192.168.4.1/settings?servo_mins=60,40,45,35,40&servo_maxs=130,130,135,125,130" -curl -X POST "http://192.168.4.1/settings?rgb_order=GRB" -curl -X POST "http://192.168.4.1/settings?oled_rotate_180=1" -curl -X POST "http://tiny-engineer.local/settings?sleep_timeout=10&hostname=tiny-engineer&volume=70&welcome=1&serial_log=0&continuous_timeout=5&loading=progress" +curl -X POST "http://tiny-engineer.local/settings" \ + -H "Content-Type: application/json" \ + -d '{"volume":40,"welcome":false,"eyes_style":"kaomoji"}' +curl -X POST "http://tiny-engineer.local/settings" \ + -H "Content-Type: application/json" \ + -d '{"access_token":""}' +curl -X POST "http://192.168.4.1/settings" \ + -H "Content-Type: application/json" \ + -d '{"servo_mins":[60,40,45,35,40],"servo_maxs":[130,130,135,125,130],"rgb_order":"GRB","oled_rotate_180":true}' ``` ```json @@ -184,23 +181,23 @@ curl -X POST "http://tiny-engineer.local/settings?sleep_timeout=10&hostname=tiny } ``` -| Param | Type | Range | +| Field | Type | Range | | --- | --- | --- | | `sleep_timeout` | integer | 1–1440 minutes (positive) | | `hostname` | string | 1–31 chars, `[A-Za-z0-9-]`, not starting/ending with `-` | | `volume` | integer | 0–100 percent | -| `welcome` | integer | `0` or `1` (boot welcome animation) | -| `serial_log` | integer | `0` or `1` (USB serial debug logging) | +| `welcome` | boolean | Boot welcome animation | +| `serial_log` | boolean | USB serial debug logging | | `continuous_timeout` | integer | 1–1440 minutes (positive) | | `loading` | string | `progress` or `sleep_inertia` | | `eyes_style` | string | `classic`, `kaomoji`, `cover`, or `dots` | -| `access_token` | string | 0–64 printable ASCII; empty string clears the token and disables auth | -| `servo_mins` | string | Five comma-separated integers `0`–`180` (`head,neck,left,right,body`); setup AP only; must be sent with `servo_maxs` | -| `servo_maxs` | string | Five comma-separated integers `0`–`180`; setup AP only; each value must be greater than the matching min | +| `access_token` | string | 0–64 printable ASCII, no space at either end; empty string clears the token and disables auth | +| `servo_mins` | integer array | Five integers `0`–`180` (`head, neck, left, right, body`); setup AP only; must be sent with `servo_maxs` | +| `servo_maxs` | integer array | Five integers `0`–`180`; setup AP only; each value must be greater than the matching min | | `rgb_order` | string | `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, or `BGR`; setup AP only; default `GRB` | -| `oled_rotate_180` | integer | `0` or `1`; setup AP only; default `0` | +| `oled_rotate_180` | boolean | Setup AP only; default `false` | -At least one param required. Invalid or missing-all → **400**. Servo range params outside setup AP → **400** `servo setup only in AP mode`. RGB order outside setup AP → **400** `rgb setup only in AP mode`. OLED rotation outside setup AP → **400** `oled setup only in AP mode`. `reboot_required` is present and `true` only when the saved hostname differs from the one used at this boot. +Leave out a field to keep its saved value. An empty object → **400** `send at least one setting`. A field this route does not know → **400** `unknown field `. A field with the wrong type or out of range → **400** `invalid ` (servo ranges that overlap or exceed 180: `invalid servo_mins or servo_maxs`). Only one of `servo_mins` / `servo_maxs` → **400** `servo_mins and servo_maxs go together`. On any 400, nothing is saved. Outside setup AP mode, servo ranges → **400** `servo setup only in AP mode`, `rgb_order` → **400** `rgb setup only in AP mode`, `oled_rotate_180` → **400** `oled setup only in AP mode`. Replies carry `reboot_required` as in `GET /settings`. ### `PUT /settings/wifi` @@ -219,11 +216,11 @@ curl -X PUT "http://tiny-engineer.local/settings/wifi" \ | `ssid` | string | 1–32 chars; unique in the list | | `password` | string | 0–63 chars; omit to keep the saved password of a saved `ssid` | -The list must hold 1 to 5 networks, so the robot always has one to try; factory reset clears WiFi. Body not a JSON array → **400** `body must be a JSON array of networks`. An entry that is not an object, or has a non-string `ssid` or `password` (including `null`) → **400** `each network needs a string ssid and an optional string password`. Empty → **400** `send at least one wifi network`. New SSID without `password` → **400** `password required for a new network`. Bad length, the same SSID twice, or more than 5 networks → **400** `invalid ssid or password, duplicate ssid, or more than 5 networks`. WiFi test failure in setup AP mode → **400** with an error such as `SSID not found`, `Auth failed`, or `Timeout`. +The list must hold 1 to 5 networks, so the robot always has one to try; factory reset clears WiFi. No JSON body → **400** `send a JSON body with Content-Type: application/json`. Body not a JSON array → **400** `body must be a JSON array`. An entry that is not an object, or has a non-string `ssid` or `password` (including `null`) → **400** `each network needs a string ssid and an optional string password`. Empty → **400** `send at least one wifi network`. New SSID without `password` → **400** `password required for a new network`. Bad length, the same SSID twice, or more than 5 networks → **400** `invalid ssid or password, duplicate ssid, or more than 5 networks`. WiFi test failure in setup AP mode → **400** with an error such as `SSID not found`, `Auth failed`, or `Timeout`. ### `POST /settings/reset` -Factory reset: clears the NVS `te` namespace and restores settings to compile-time defaults and clears every saved WiFi network. **Servo min/max, RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are kept.** After reset, power-cycle (or press reset) so the device reopens setup AP mode and WiFi can be configured again; the servo, screen, and LED steps are pre-filled with the saved values and can be retuned. Until reboot, credentials are cleared in NVS but the radio may still be on the previous STA network. No query params. Requires Bearer when auth is enabled. Same JSON shape as `GET /settings`; includes `reboot_required` when the boot hostname, loading screen, or saved WiFi differed from defaults before reset. +Factory reset: clears the NVS `te` namespace and restores settings to compile-time defaults and clears every saved WiFi network. **Servo min/max, RGB LED mapping (`rgb_order`), and OLED rotation (`oled_rotate_180`) are kept.** After reset, power-cycle (or press reset) so the device reopens setup AP mode and WiFi can be configured again; the servo, screen, and LED steps are pre-filled with the saved values and can be retuned. Until reboot, credentials are cleared in NVS but the radio may still be on the previous STA network. No body. Requires Bearer when auth is enabled. Same JSON shape as `GET /settings`. The Config web UI clears the browser-stored Bearer token and shows a one-shot gate: power-cycle the device, then join the robot WiFi shown on the OLED and open the setup wizard. It does not poll for reconnect. @@ -319,78 +316,73 @@ curl -X POST http://tiny-engineer.local/test/led ### `POST /setup/servo` -Setup AP only. Slowly move one servo, or all five to 90°, using the full **0–180°** electrical range (no saved safe-range clamp). ~25°/s (`SERVO_CALIB_SPEED_DEG_S`). Used by the setup wizard for seating printed parts at 90° and for range finding. Handler blocks until the move finishes. Does **not** require Wi-Fi credentials. Outside provisioning → **400** `setup servo only in AP mode`. +Setup AP only. Slowly move one servo, or all five to 90°, using the full **0–180°** electrical range (no saved safe-range clamp). ~25°/s (`SERVO_CALIB_SPEED_DEG_S`). Used by the setup wizard for seating printed parts at 90° and for range finding. Handler blocks until the move finishes. Does **not** require Wi-Fi credentials. Outside provisioning → **400** `setup only in AP mode`. -Either `all=90`, or `index` + `angle`. +JSON body: either `{"all": 90}`, or `index` + `angle`. -| Param | Type | Range | +| Field | Type | Range | | --- | --- | --- | | `all` | integer | `90` (every joint to 90°) | | `index` | integer | `0`–`4` (required with `angle` when `all` is omitted) | | `angle` | number | `0`–`180` | ```bash -curl -X POST "http://192.168.4.1/setup/servo?all=90" -curl -X POST "http://192.168.4.1/setup/servo?index=0&angle=90" +curl -X POST "http://192.168.4.1/setup/servo" -H "Content-Type: application/json" -d '{"all":90}' +curl -X POST "http://192.168.4.1/setup/servo" -H "Content-Type: application/json" -d '{"index":0,"angle":90}' ``` ```json { "ok": true, "setup": "servo", "index": 0, "angle": 90 } ``` -Wrong params return **400** and do not move any servo: +Wrong fields return **400** and do not move any servo: | `error` | When | | --- | --- | -| `setup servo only in AP mode` | Not in setup AP | +| `setup only in AP mode` | Not in setup AP | | `invalid all` | `all` present but not `90` | -| `missing index or angle` | Neither `all=90` nor both `index` and `angle` | -| `invalid index` | Non-integer `index` | -| `index out of range` | `index` outside `0`–`4` | -| `invalid angle` | Non-numeric `angle` | -| `angle out of range` | `angle` outside `0`–`180` | +| `invalid index` | `index` missing, not an integer, or outside `0`–`4` | +| `invalid angle` | `angle` missing, not a number, or outside `0`–`180` | After STA is up, use `POST /test/servo` (clamped to saved min/max). ### `POST /setup/led` -Setup AP only. Holds the LED so provisioning dim-blue does not clobber the preview. Does **not** require Wi-Fi credentials. Outside provisioning → **400** `setup led only in AP mode`. +Setup AP only. Holds the LED so provisioning dim-blue does not clobber the preview. Does **not** require Wi-Fi credentials. Outside provisioning → **400** `setup only in AP mode`. -Logical **color** (`R` / `G` / `B`) lights firmware red/green/blue using the saved `rgb_order`, or `rgb_order` query if sent (wizard remap preview). Wire **byte** lights that WS2812 channel order-independent (`LED_COLOR_ORDER_RGB`, that channel 255). `byte=off` or neither `color` nor `byte` releases the hold and restores dim blue. Do not send `color` and `byte` together. +Logical **color** (`R` / `G` / `B`) lights firmware red/green/blue using the saved `rgb_order`, or the `rgb_order` field if sent (wizard remap preview). Wire **byte** lights that WS2812 channel order-independent (`LED_COLOR_ORDER_RGB`, that channel 255). An empty body (neither `color` nor `byte`) releases the hold and restores dim blue. Do not send `color` and `byte` together. -| Param | Type | Range | +| Field | Type | Range | | --- | --- | --- | | `color` | string | `R`, `G`, or `B` (logical; uses saved or `rgb_order`) | | `rgb_order` | string | `RGB`, `RBG`, `GRB`, `GBR`, `BRG`, or `BGR` (optional with `color`) | -| `byte` | string | `0`, `1`, `2`, or `off` (omit or `off` releases the hold) | +| `byte` | integer | `0`, `1`, or `2` | ```bash -curl -X POST "http://192.168.4.1/setup/led?color=R" -curl -X POST "http://192.168.4.1/setup/led?color=R&rgb_order=GRB" -curl -X POST "http://192.168.4.1/setup/led?byte=0" -curl -X POST "http://192.168.4.1/setup/led?byte=off" +curl -X POST "http://192.168.4.1/setup/led" -H "Content-Type: application/json" -d '{"color":"R","rgb_order":"GRB"}' +curl -X POST "http://192.168.4.1/setup/led" -H "Content-Type: application/json" -d '{"byte":0}' +curl -X POST "http://192.168.4.1/setup/led" ``` ```json { "ok": true, "setup": "led", "color": "R" } ``` -Wrong params return **400** and do not change hold state except `off` / omitted light params: +Wrong fields return **400** and do not change the hold: | `error` | When | | --- | --- | -| `setup led only in AP mode` | Not in setup AP | -| `color and byte conflict` | Both `color` and a numeric `byte` present | +| `setup only in AP mode` | Not in setup AP | +| `color and byte conflict` | Both `color` and `byte` present | | `invalid color` | `color` not `R`, `G`, or `B` | | `invalid rgb_order` | `rgb_order` present but not a valid permutation | -| `invalid byte` | `byte` present but not an integer and not `off` | -| `byte out of range` | Integer `byte` outside `0`–`2` | +| `invalid byte` | `byte` not an integer `0`–`2` | -Save the labeled permutation with `POST /settings?rgb_order=GRB` (setup AP only). The saved order applies immediately to animation colors, boot status, halt blinks, and `POST /test/led`. +Save the labeled permutation with `POST /settings` `{"rgb_order":"GRB"}` (setup AP only). The saved order applies immediately to animation colors, boot status, halt blinks, and `POST /test/led`. ### `POST /setup/audio` -Setup AP only. Plays `welcome.wav` from LittleFS (`playWelcome()`, ~2.7 s). Same speaker gain as settings `volume`. No query params. Does **not** require Wi-Fi credentials. Handler blocks until playback finishes. Outside provisioning → **400** `setup audio only in AP mode`. Missing/unreadable WAV → **500** `welcome playback failed`. +Setup AP only. Plays `welcome.wav` from LittleFS (`playWelcome()`, ~2.7 s). Same speaker gain as settings `volume`. No body. Does **not** require Wi-Fi credentials. Handler blocks until playback finishes. Outside provisioning → **400** `setup only in AP mode`. Missing/unreadable WAV → **500** `welcome playback failed`. ```bash curl -X POST "http://192.168.4.1/setup/audio" @@ -404,16 +396,16 @@ After STA is up, use `POST /test/audio/bell` (or `/test/audio` for tones). ### `POST /setup/oled` -Setup AP only. Preview OLED 180° rotation without saving. Does **not** require Wi-Fi credentials. Outside provisioning → **400** `setup oled only in AP mode`. +Setup AP only. Preview OLED 180° rotation without saving. Does **not** require Wi-Fi credentials. Outside provisioning → **400** `setup only in AP mode`. -`rotate_180=1` applies `setRotation(2)` and draws `THIS WAY UP`. `rotate_180=0` applies `setRotation(0)` and draws the same test. Omit `rotate_180` to restore saved rotation and the provisioning join-AP text. +`{"rotate_180": true}` applies `setRotation(2)` and draws `THIS WAY UP`. `{"rotate_180": false}` applies `setRotation(0)` and draws the same test. An empty body restores the saved rotation and the provisioning join-AP text. -| Param | Type | Range | +| Field | Type | Range | | --- | --- | --- | -| `rotate_180` | integer | `0` or `1` (omit to restore provisioning text) | +| `rotate_180` | boolean | Leave out to restore the provisioning text | ```bash -curl -X POST "http://192.168.4.1/setup/oled?rotate_180=1" +curl -X POST "http://192.168.4.1/setup/oled" -H "Content-Type: application/json" -d '{"rotate_180":true}' curl -X POST "http://192.168.4.1/setup/oled" ``` @@ -421,41 +413,38 @@ curl -X POST "http://192.168.4.1/setup/oled" { "ok": true, "setup": "oled", "rotate_180": true } ``` -Wrong params return **400**: +Wrong fields return **400**: | `error` | When | | --- | --- | -| `setup oled only in AP mode` | Not in setup AP | -| `invalid rotate_180` | `rotate_180` present but not `0` or `1` | +| `setup only in AP mode` | Not in setup AP | +| `invalid rotate_180` | `rotate_180` present but not a boolean | -Save with `POST /settings?oled_rotate_180=1` (setup AP only). The saved value applies immediately and on the next boot. +Save with `POST /settings` `{"oled_rotate_180":true}` (setup AP only). The saved value applies immediately and on the next boot. ### `POST /test/servo` -Smoothly move one servo to an angle at **~140°/s** (`SERVO_MAX_SPEED_DEG_S`) from its last commanded position. Query params required. Handler blocks until the move finishes. +Smoothly move one servo to an angle at **~140°/s** (`SERVO_MAX_SPEED_DEG_S`) from its last commanded position. JSON body required. Handler blocks until the move finishes. -| Param | Type | Range | +| Field | Type | Range | | --- | --- | --- | | `index` | integer | `0`–`4` | | `angle` | number | `0`–`180` | ```bash -curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" +curl -X POST "http://tiny-engineer.local/test/servo" -H "Content-Type: application/json" -d '{"index":0,"angle":90}' ``` ```json { "ok": true, "test": "servo", "index": 0, "angle": 90 } ``` -Wrong params return **400** and do not move any servo: +Wrong fields return **400** and do not move any servo: | `error` | When | | --- | --- | -| `missing index or angle` | Either query param absent | -| `invalid index` | Non-integer `index` (e.g. `abc`, `1x`) | -| `index out of range` | `index` outside `0`–`4` | -| `invalid angle` | Non-numeric `angle` | -| `angle out of range` | `angle` outside `0`–`180` | +| `invalid index` | `index` missing, not an integer, or outside `0`–`4` | +| `invalid angle` | `angle` missing, not a number, or outside `0`–`180` | Assembled robot: firmware clamps to the NVS min/max from setup (stock defaults in [robot-movement.md](robot-movement.md)); this route still accepts 0–180 and clamps. For unclamped assembly moves, use setup AP `POST /setup/servo`. @@ -591,11 +580,11 @@ Errors arrive before any audio plays: | Status | Body | When | | --- | --- | --- | -| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/anim`, or `/play` params (see tables above) | +| `400` | `{"ok":false,"error":"..."}` | Bad body or fields on `/settings`, `/settings/wifi`, `/test/servo` or `/setup/*`, or bad `/anim` or `/play` params (see tables above) | | `401` | `{"ok":false,"error":"unauthorized"}` | Access token configured and `Authorization: Bearer` missing or wrong | | `404` | `{"ok":false,"error":"not found"}` | Unknown path | | `415` | `{"ok":false,"error":"..."}` | `/play` body is not a supported WAV | -| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/settings`, `/settings/reset`, `/settings/wifi`, `/anim`, `/play`, or `/auth` path | +| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on any known path, web panel pages included | Test routes are **POST**. GET/prefetch would move hardware. `/anim` allows **GET** (read) and **POST** (set). `/auth` is **GET** only and always public. diff --git a/docs/hardware-for-software-engineers/08-tools-debugging-and-embedded-workflow.md b/docs/hardware-for-software-engineers/08-tools-debugging-and-embedded-workflow.md index 8ea7314a..35082317 100644 --- a/docs/hardware-for-software-engineers/08-tools-debugging-and-embedded-workflow.md +++ b/docs/hardware-for-software-engineers/08-tools-debugging-and-embedded-workflow.md @@ -73,7 +73,7 @@ Don't assemble everything, power on, and hope. **Bisect** like a failing integra | --- | --- | | 1. USB power only | ESP32 boots, serial output, dim green LED | | 2. + I2C (PCA9685, OLED) | Serial lists devices; PCA9685 required | -| 3. + one servo on ch 0 | `/test/servo?index=0&angle=90` moves it | +| 3. + one servo on ch 0 | `POST /test/servo` with `{"index":0,"angle":90}` moves it | | 4. + all servos | Each channel, safe ranges | | 5. + I2S amp + speaker | Audio test endpoint or animation with sound | | 6. + mechanical shell | Range of motion, no binding | diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 256a5bc0..91af3ca2 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. @@ -100,21 +100,18 @@ curl -X POST http://tiny-engineer.local/test/movement curl -X POST http://tiny-engineer.local/test/led # One servo smooth move to angle (~140°/s; index 0–4, angle 0–180) -curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" +curl -X POST "http://tiny-engineer.local/test/servo" -H "Content-Type: application/json" -d '{"index":0,"angle":90}' ``` | Method | Path | Body | | --- | --- | --- | | `GET` | `/` | HTML endpoint index | -| `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `provisioning`) — always public | +| `GET` | `/auth` | Auth status (`ok`, `version`, `required`, `wifi_configured`, `provisioning`) — always public | | `GET` | `/health` | Health JSON — full field list in [api.md](../api.md#get-health) | | `GET` / `POST` | `/anim` | Current animation / start one (`name`, optional `interrupt`) — full params in [api.md](../api.md#post-anim) | | `POST` | `/play` | Stream a WAV while `talking` (or `/play/{name}`) runs; answers when the clip ends — full params in [api.md](../api.md#post-play) | | `GET` | `/settings` | Persistent settings (`sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token_set`, `wifi_configured`, `servo_mins`, `servo_maxs`, `rgb_order`, `oled_rotate_180`, `wifi_networks`) | -| `POST` | `/settings?...` | Update NVS settings; `reboot_required` if hostname changed | -| `POST` | `/settings?...&servo_mins=&servo_maxs=` | Servo min/max comma lists; setup-AP-only | -| `POST` | `/settings?...&rgb_order=` | WS2812 byte order (`RGB`/`RBG`/`GRB`/`GBR`/`BRG`/`BGR`); setup-AP-only; default `GRB` | -| `POST` | `/settings?...&oled_rotate_180=` | OLED 180° rotation (`0`/`1`); setup-AP-only; default `0` | +| `POST` | `/settings` | Update NVS settings from a JSON object; the reply carries `reboot_required`, true once `hostname`, `loading` or the WiFi networks differ from what the robot booted with. `servo_mins` / `servo_maxs` (integer arrays), `rgb_order` and `oled_rotate_180` (boolean) are setup-AP-only | | `PUT` | `/settings/wifi` | Replace the saved WiFi networks (JSON array of 1 to 5); first one tested in setup AP mode | | `POST` | `/settings/reset` | Factory reset settings to defaults (clears WiFi, keeps servo ranges, RGB mapping, and screen rotation); power-cycle to reopen setup AP | | `POST` | `/test/audio` | `{"ok":true,"test":"audio"}` after `runSoundTest()` | @@ -122,13 +119,13 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" | `POST` | `/test/screen` | `{"ok":true,"test":"screen"}` after `runOledTest()` | | `POST` | `/test/movement` | `{"ok":true,"test":"movement"}` after `runServoTest()` | | `POST` | `/test/led` | `{"ok":true,"test":"led"}` after `runRgbTest()` | -| `POST` | `/test/servo?index=&angle=` | `{"ok":true,"test":"servo","index":N,"angle":A}` after `moveServoSmooth()` | -| `POST` | `/setup/servo?all=90` or `?index=&angle=` | Setup AP only; slow 0–180° move (`SERVO_CALIB_SPEED_DEG_S`) | -| `POST` | `/setup/led?color=R` / `G` / `B` or `?byte=0` / `1` / `2` / `off` | Setup AP only; light logical RGB or one WS2812 wire byte, or release hold | +| `POST` | `/test/servo` `{"index","angle"}` | `{"ok":true,"test":"servo","index":N,"angle":A}` after `moveServoSmooth()` | +| `POST` | `/setup/servo` `{"all":90}` or `{"index","angle"}` | Setup AP only; slow 0–180° move (`SERVO_CALIB_SPEED_DEG_S`) | +| `POST` | `/setup/led` `{"color":"R"}` / `G` / `B` or `{"byte":0}` / `1` / `2` | Setup AP only; light logical RGB or one WS2812 wire byte; an empty body releases the hold | | `POST` | `/setup/audio` | Setup AP only; play `welcome.wav` (`playWelcome()`) | -| `POST` | `/setup/oled?rotate_180=0` / `1` | Setup AP only; preview OLED 180° rotation (`THIS WAY UP`); omit param to restore provisioning text | +| `POST` | `/setup/oled` `{"rotate_180":true}` / `false` | Setup AP only; preview OLED 180° rotation (`THIS WAY UP`); an empty body restores the provisioning text | -GET on a test path returns `405`. Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, or `/settings` params return `400`. Missing/wrong Bearer when auth enabled returns `401`. Control APIs return `503` when WiFi credentials are not saved (`/setup/servo`, `/setup/led`, `/setup/audio`, and `/setup/oled` stay available). Unknown path returns `404`. JSON `Content-Type`. Handlers block until the test finishes; the OLED returns to `ROBOT READY` after. +GET on a test path returns `405`. A bad JSON body or field on `/test/servo`, `/setup/*`, or `/settings` returns `400`. Missing/wrong Bearer when auth enabled returns `401`. Control APIs return `503` when WiFi credentials are not saved (`/setup/servo`, `/setup/led`, `/setup/audio`, and `/setup/oled` stay available). Unknown path returns `404`. JSON `Content-Type`. Handlers block until the test finishes; the OLED returns to `ROBOT READY` after. HTTP runs on STA when connected, or on setup AP at `192.168.4.1` during provisioning. diff --git a/docs/robot-movement.md b/docs/robot-movement.md index f6eb8118..4ac33043 100644 --- a/docs/robot-movement.md +++ b/docs/robot-movement.md @@ -19,7 +19,7 @@ Animations author poses in **−1..1** relative to each joint’s saved min/max Command a single joint for bench checks: ```bash -curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" +curl -X POST "http://tiny-engineer.local/test/servo" -H "Content-Type: application/json" -d '{"index":0,"angle":90}' ``` Use angles inside the saved safe range on the assembled robot. During setup AP, `POST /setup/servo` can command the full 0–180° electrical range so you can find those limits. diff --git a/docs/settings.md b/docs/settings.md index 5583b23c..6cc212d8 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -2,7 +2,7 @@ Persistent settings live in NVS (namespace `te`) under [`src/settings/`](../src/settings/). They are exposed on `GET`/`POST /settings`, the Config web UI, and must stay in sync with API docs. -**HTTP / JSON** (query params and `GET /settings` fields — see [api.md](api.md)): `sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token`, `servo_mins` / `servo_maxs`, `rgb_order`, `oled_rotate_180`. WiFi networks have their own endpoint, `PUT /settings/wifi`, and show as `wifi_networks`. +**HTTP / JSON** (`POST /settings` body and `GET /settings` fields — see [api.md](api.md)): `sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token`, `servo_mins` / `servo_maxs`, `rgb_order`, `oled_rotate_180`. WiFi networks have their own endpoint, `PUT /settings/wifi`, and show as `wifi_networks`. **NVS keys** (≤15 chars; defined in [`nvs.h`](../src/settings/nvs.h)): `sleep_m`, `host`, `vol`, `welcome`, `serial_log`, `cont_to`, `loading`, `eyes_sty`, `access_tok`, `wifi_nets` (blob of up to 5 networks; replaces the older single-network `wifi_ssid` / `wifi_pass`, which are migrated on load), `sranges`, `rgb_ord`, `oled_rot`. Add new short keys there — do not reuse HTTP names as NVS keys unless they already fit the length limit. @@ -13,10 +13,10 @@ 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 | -| 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 | +| RAM | One `SettingsValues` struct (`g_settings`) filled by `initSettings()`; callers use getters | +| Update API | `saveSettings(const SettingsPatch&)`: only the fields the patch sets change, and only changed keys are written to NVS | +| HTTP | JSON object body on `POST /settings`; at least one field required | +| Apply timing | Prefer immediate apply. If boot-only (like hostname), add it to `settingsRebootRequired()` so replies report `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 | ## Checklist @@ -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 `writeValues()` ([`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 only the keys whose value changed. ### 2. Consumers @@ -42,19 +39,14 @@ Call the getter wherever the value affects behavior (e.g. `settingsVolume()` in ### 3. HTTP — [`src/http/settings_handlers.cpp`](../src/http/settings_handlers.cpp) -1. Include the field in both `snprintf` branches of `sendSettingsJson` (with and without `reboot_required`). -3. In `handleSettingsPost`: - - `server.hasArg("foo")` - - Parse (same style as `sleep_timeout` / `volume`: `strtoul` + end-pointer check for integers) - - Validate → **400** with a clear `error` string - - Pass pointer into `saveSettings` -4. Update the “missing …” **400** message to list the new param. +1. Add the field to `settingsJson()`. +2. Read it into the patch with one `read("foo", patch.foo)` line in `readPatch()`. A wrong JSON type answers **400** `invalid `, and so does a value `SettingsPatch::invalidField()` rejects. Fields `readPatch()` does not read answer **400** `unknown field `. ### 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/ConfigForm.tsx`](../ui/src/config/ConfigForm.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) @@ -79,15 +71,15 @@ Flash only when you want to try it on hardware (`pio run -t upload`). | --- | --- | | Settings | `SETTINGS_DEFAULT_VOLUME = 70`, key `vol`, `settingsVolume()`, range 0–100 | | Audio | Tone amplitude and WAV PCM scaled by `settingsVolume() / 100` | -| API | `"volume":70` on GET; `POST /settings?volume=40` | +| API | `"volume":70` on GET; `POST /settings` `{"volume":40}` | | UI | Slider + number on Config; live `N%` label | | Docs | `api.md` + README | ## 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. -- **Hostname-style settings:** freeze the boot value separately if live change cannot apply (see `settingsBootHostname()` / `reboot_required`). +- **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 `g_bootSettings` and `settingsRebootRequired()`). - **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/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..1790123e 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) @@ -37,7 +37,7 @@ def newest_source_mtime(): npm("run", "build") page = (ui_dir / "dist" / "index.html").read_bytes() -# mtime=0 keeps the bytes identical for identical sources, so an unchanged UI does not rebuild index_page.cpp. +# mtime=0 keeps the bytes identical for identical sources, so an unchanged UI does not rebuild routes.cpp. packed = gzip.compress(page, compresslevel=9, mtime=0) rows = ",\n".join( " " + ", ".join(f"0x{byte:02x}" for byte in packed[offset:offset + 16]) diff --git a/src/display/eyes/styles/README.md b/src/display/eyes/styles/README.md index ed204628..bc6fc33c 100644 --- a/src/display/eyes/styles/README.md +++ b/src/display/eyes/styles/README.md @@ -1,6 +1,6 @@ # OLED eye styles -Extensible eye renderers selected by Config / `POST /settings?eyes_style=…`. +Extensible eye renderers selected by Config / `POST /settings` `{"eyes_style":"…"}`. | Id | Meaning | | --- | --- | diff --git a/src/http/access.cpp b/src/http/access.cpp new file mode 100644 index 00000000..c4a35c09 --- /dev/null +++ b/src/http/access.cpp @@ -0,0 +1,34 @@ +#include "http/access.h" + +#include "http/response.h" +#include "network/wifi_connect.h" +#include "settings/settings.h" + +namespace { + +bool tokenAccepted(WebServer& server) { + return !settingsAccessTokenSet() || + server.header("Authorization").equalsConstantTime(String("Bearer ") + settingsAccessToken()); +} + +} // namespace + +std::optional Refusal::check(WebServer& server, Access access) { + if (access == Access::Configured && !settingsWifiConfigured()) { + return Refusal{503, "wifi not configured"}; + } + + if (access != Access::Public && !tokenAccepted(server)) { + return Refusal{401, "unauthorized"}; + } + + if (access == Access::Setup && !wifiProvisioningMode()) { + return Refusal{400, "setup only in AP mode"}; + } + + return std::nullopt; +} + +void Refusal::send(WebServer& server) const { + sendError(server, code, message); +} diff --git a/src/http/access.h b/src/http/access.h new file mode 100644 index 00000000..bc477d58 --- /dev/null +++ b/src/http/access.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include + +enum class Access { + Public, + Token, + // Token, and refused with 503 until WiFi credentials are saved. + Configured, + // Token, and only in setup AP mode: these routes drive the hardware without the saved limits. + Setup, +}; + +// Why a request may not reach its route. +struct Refusal { + int code; + const char* message; + + static std::optional check(WebServer& server, Access access); + void send(WebServer& server) const; +}; diff --git a/src/http/anim_handlers.cpp b/src/http/anim_handlers.cpp index 0e456999..6ae8b5e3 100644 --- a/src/http/anim_handlers.cpp +++ b/src/http/anim_handlers.cpp @@ -1,53 +1,29 @@ -#include "http/anim_handlers.h" - #include -#include #include "animation.h" -#include "http/json.h" - -namespace { - -void sendAnimationJson(WebServer& server) { - char body[64]; - - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"animation\":\"%s\"}", - animationName(getAnimation()) - ); - - httpSendJson(server, 200, body); -} - -} // namespace +#include "http/handlers.h" +#include "http/response.h" void handleAnimGet(WebServer& server) { - sendAnimationJson(server); + JsonDocument body; + body["ok"] = true; + body["animation"] = animationName(getAnimation()); + sendJson(server, 200, body); } 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); + handleAnimGet(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..a5b72370 100644 --- a/src/http/health_handlers.cpp +++ b/src/http/health_handlers.cpp @@ -1,87 +1,53 @@ -#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["version"] = FW_VERSION; + 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"] = wifiMdnsHostname(); + + 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/play_handlers.cpp b/src/http/play_handlers.cpp index 1da8bab3..295f23b7 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/access.h" +#include "http/handlers.h" +#include "http/response.h" #include "pins.h" #include "robot_tick.h" #include "settings/settings.h" @@ -26,16 +25,10 @@ class PlaySession : public WavStreamParser::Sink { public: void begin(WebServer& server) { parser_.reset(); - rejectCode_ = 0; started_ = false; + refusal_ = Refusal::check(server, Access::Configured); - if (!settingsWifiConfigured()) { - reject(503, "wifi not configured"); - return; - } - - if (!httpApiAuthorized(server)) { - reject(401, "unauthorized"); + if (refusal_) { return; } @@ -46,7 +39,7 @@ class PlaySession : public WavStreamParser::Sink { // Continuous animations and none play no sound of their own, so they cannot talk over the clip. if (!parseAnimationName(name, animation_) || (animation_ != AnimationId::None && !animationIsContinuous(animation_))) { - reject(400, "name must be talking, typing, reading, thinking or none"); + refusal_ = Refusal{400, "name must be talking, typing, reading, thinking or none"}; return; } @@ -79,19 +72,20 @@ class PlaySession : public WavStreamParser::Sink { } void respond(WebServer& server) { - if (rejectCode_ != 0) { - httpSendJson(server, rejectCode_, body_); + if (refusal_) { + refusal_->send(server); 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 { @@ -121,17 +115,7 @@ class PlaySession : public WavStreamParser::Sink { AnimationId animation_ = AnimationId::Talking; AnimationId previous_ = AnimationId::None; bool started_ = false; - int rejectCode_ = 0; - char body_[96] = {}; - - 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); - } + std::optional refusal_; uint32_t playedMs() const { return static_cast(parser_->samplesDelivered() * 1000ULL / SAMPLE_RATE); 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..356d8460 --- /dev/null +++ b/src/http/request.cpp @@ -0,0 +1,54 @@ +#include "http/request.h" + +#include + +#include "http/response.h" +#include "servos.h" + +template +bool readJsonBody(WebServer& server, JsonDocument& body) { + constexpr bool kObject = std::is_same_v; + + if (!server.hasArg("plain")) { + // WebServer keeps "plain" only for bodies that are not form-encoded. + if (server.args() > 0 || server.clientContentLength() > 0 || !kObject) { + sendError(server, 400, "send a JSON body with Content-Type: application/json"); + return false; + } + + body.to(); + return true; + } + + if (deserializeJson(body, server.arg("plain")) || !body.is()) { + sendError(server, 400, kObject ? "body must be a JSON object" : "body must be a JSON array"); + return false; + } + + return true; +} + +template bool readJsonBody(WebServer& server, JsonDocument& body); +template bool readJsonBody(WebServer& server, JsonDocument& body); + +std::optional ServoMove::read(WebServer& server, JsonObjectConst body) { + const JsonVariantConst index = body["index"]; + const JsonVariantConst angle = body["angle"]; + + if (!index.is() || index.as() < 0 || index.as() >= SERVO_COUNT) { + sendError(server, 400, "invalid index"); + return std::nullopt; + } + + if (!angle.is() || angle.as() < 0.0f || angle.as() > 180.0f) { + sendError(server, 400, "invalid angle"); + return std::nullopt; + } + + return ServoMove{index.as(), angle.as()}; +} + +void ServoMove::addTo(JsonDocument& reply) const { + reply["index"] = index; + reply["angle"] = angle; +} diff --git a/src/http/request.h b/src/http/request.h new file mode 100644 index 00000000..06a9626c --- /dev/null +++ b/src/http/request.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include + +// Parses a JSON body whose root is a `Root` (JsonObjectConst or JsonArrayConst) +// into `body`; no body reads as {} for an object. Sends 400 and returns false +// for anything else, query parameters and form fields included. +template +bool readJsonBody(WebServer& server, JsonDocument& body); + +struct ServoMove { + int index; + float angle; + + // Sends 400 and returns nullopt when the body holds no valid move. A move that + // passes is one the servo driver accepts: the index is the only thing it checks. + static std::optional read(WebServer& server, JsonObjectConst body); + + void addTo(JsonDocument& reply) const; +}; 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..1efb6a79 100644 --- a/src/http/routes.cpp +++ b/src/http/routes.cpp @@ -1,116 +1,116 @@ #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/access.h" +#include "http/handlers.h" +#include "http/response.h" #include "network/wifi_connect.h" +#include "web_ui.h" namespace { -void handleIndex() { - WebServer& server = httpServer(); +struct Route { + const char* path; + HTTPMethod method; + void (*handler)(WebServer&); + Access access; +}; - if (wifiProvisioningMode() && server.uri() == "/") { - server.sendHeader( - "Location", - String("http://") + wifiApIpText() + "/config", - true - ); - server.send(302, "text/plain", "Redirecting to setup"); - return; +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::Setup}, + {"/setup/led", HTTP_POST, handleSetupLed, Access::Setup}, + {"/setup/audio", HTTP_POST, handleSetupAudio, Access::Setup}, + {"/setup/oled", HTTP_POST, handleSetupOled, Access::Setup}, +}; + +bool isKnownPath(const String& uri) { + for (const Route& route : kRoutes) { + if (uri == route.path) { + return true; + } } - sendIndexPage(httpServer()); + return uri == "/play" || uri.startsWith("/play/"); } -void handleNotFound() { - WebServer& server = httpServer(); +void redirectToSetup(WebServer& server) { + server.sendHeader("Location", String("http://") + wifiApIpText() + "/config", true); + server.send(302, "text/plain", "Redirecting to setup"); +} - if (server.method() == HTTP_OPTIONS) { - httpSendCorsPreflight(server); +void handleNotFound(WebServer& server) { + if (wifiProvisioningMode() && server.method() == HTTP_GET) { + redirectToSetup(server); return; } - if (wifiProvisioningMode() && server.method() == HTTP_GET) { - server.sendHeader("Location", String("http://") + wifiApIpText() + "/config", true); - server.send(302, "text/plain", "Redirecting to setup"); + if (isKnownPath(server.uri())) { + sendError(server, 405, "method not allowed"); return; } - if (isHttpTestPath(server.uri()) || isHttpSetupPath(server.uri()) || - isSettingsOrAnimPath(server.uri())) { - httpSendJson( - server, - 405, - "{\"ok\":false,\"error\":\"method not allowed\"}" - ); + sendError(server, 404, "not found"); +} + +} // namespace + +void handleIndex(WebServer& server) { + if (wifiProvisioningMode() && server.uri() == "/") { + redirectToSetup(server); return; } - httpSendJson( - server, - 404, - "{\"ok\":false,\"error\":\"not found\"}" - ); + 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)); } -} // namespace +void registerHttpRoutes(WebServer& server) { + for (const Route& route : kRoutes) { + // A reference into kRoutes keeps the capture small enough for std::function to hold inline. + server.on(route.path, route.method, [&server, &route]() { + if (const std::optional refusal = Refusal::check(server, route.access)) { + refusal->send(server); + return; + } + + route.handler(server); + }); + } -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); - }); // /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..6934fab4 --- /dev/null +++ b/src/http/server.cpp @@ -0,0 +1,65 @@ +#include "http/server.h" + +#include +#include +#include + +#include "http/routes.h" +#include "network/wifi_connect.h" +#include "serial_log.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 = wifiMdnsHostname(); + + 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..66ea7b8b 100644 --- a/src/http/settings_handlers.cpp +++ b/src/http/settings_handlers.cpp @@ -1,56 +1,110 @@ -#include "http/settings_handlers.h" - #include -#include -#include -#include -#include + +#include #include "display/oled.h" -#include "http/json.h" -#include "http/server_context.h" +#include "http/handlers.h" +#include "http/request.h" +#include "http/response.h" #include "network/wifi_connect.h" #include "settings/settings.h" #include "settings/wifi_network_list.h" namespace { -bool parseServoCsv(const String& raw, uint8_t out[SETTINGS_SERVO_COUNT]) { - const char* p = raw.c_str(); +template +bool readField(JsonVariantConst value, std::optional& out) { + if (!value.is()) { + return false; + } + + out = value.as(); + return true; +} + +bool readField(JsonVariantConst value, const char*& out) { + out = value.as(); + return out != nullptr; +} + +bool readField(JsonVariantConst list, uint8_t (&out)[SETTINGS_SERVO_COUNT]) { + if (!list.is() || list.size() != SETTINGS_SERVO_COUNT) { + return false; + } for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { - if (p == nullptr || *p == '\0') { + if (!list[i].is()) { return false; } - char* end = nullptr; - const unsigned long parsed = strtoul(p, &end, 10); + out[i] = list[i].as(); + } - if (end == p || parsed > 255) { - return false; + return true; +} + +// Fills the patch from the body. Returns the 400 message, or an empty string +// when every field is known and has the right JSON type. +String readPatch(JsonObjectConst body, SettingsPatch& patch) { + const char* known[16] = {}; + size_t knownCount = 0; + const char* wrong = nullptr; + + auto read = [&](const char* key, auto& out) { + // A missing key keeps the saved value; an explicit null is a wrong type. + if (body[key].isUnbound()) { + return; } - if (i + 1 < SETTINGS_SERVO_COUNT) { - if (*end != ',') { - return false; - } + known[knownCount++] = key; - p = end + 1; - } else if (*end != '\0') { - return false; + if (wrong == nullptr && !readField(body[key], out)) { + wrong = key; + } + }; + + read("sleep_timeout", patch.sleepTimeoutMin); + read("hostname", patch.hostname); + read("volume", patch.volume); + read("welcome", patch.welcome); + read("serial_log", patch.serialLog); + read("continuous_timeout", patch.continuousTimeoutMin); + read("loading", patch.loading); + read("eyes_style", patch.eyesStyle); + read("access_token", patch.accessToken); + read("rgb_order", patch.rgbOrder); + read("oled_rotate_180", patch.oledRotate180); + + ServoRanges ranges{}; + read("servo_mins", ranges.mins); + read("servo_maxs", ranges.maxs); + + for (const JsonPairConst field : body) { + const auto isKnown = [&](const char* key) { return field.key() == key; }; + + if (std::none_of(known, known + knownCount, isKnown)) { + return String("unknown field ") + field.key().c_str(); } + } - out[i] = static_cast(parsed); + if (wrong != nullptr) { + return String("invalid ") + wrong; } - return true; + const bool hasMins = !body["servo_mins"].isUnbound(); + + if (hasMins != !body["servo_maxs"].isUnbound()) { + return "servo_mins and servo_maxs go together"; + } + + if (hasMins) { + patch.servoRanges = ranges; + } + + return ""; } -void sendSettingsJson( - WebServer& server, - bool rebootRequired, - bool wifiConnectSuccess -) { +JsonDocument settingsJson() { JsonDocument doc; doc["ok"] = true; @@ -83,403 +137,64 @@ void sendSettingsJson( ssids.add(networks.at(i).ssid); } - if (wifiConnectSuccess) { - doc["wifi_connect_success"] = true; - doc["wifi_ip"] = wifiIpText(); - doc["wifi_hostname"] = httpMdnsHostname(); - } - - if (rebootRequired) { - doc["reboot_required"] = true; - } - - String body; - body.reserve(measureJson(doc)); - serializeJson(doc, body); - httpSendJson(server, 200, body.c_str()); + doc["reboot_required"] = settingsRebootRequired(); + return doc; } } // namespace void handleSettingsGet(WebServer& server) { - sendSettingsJson(server, false, false); + sendJson(server, 200, settingsJson()); } void handleSettingsPost(WebServer& server) { - const bool hasSleep = server.hasArg("sleep_timeout"); - const bool hasHost = server.hasArg("hostname"); - const bool hasVolume = server.hasArg("volume"); - const bool hasWelcome = server.hasArg("welcome"); - const bool hasSerialLog = server.hasArg("serial_log"); - const bool hasContTo = server.hasArg("continuous_timeout"); - const bool hasLoading = server.hasArg("loading"); - const bool hasEyesStyle = server.hasArg("eyes_style"); - const bool hasAccessToken = server.hasArg("access_token"); - const bool hasServoMins = server.hasArg("servo_mins"); - const bool hasServoMaxs = server.hasArg("servo_maxs"); - const bool hasRgbOrder = server.hasArg("rgb_order"); - const bool hasOledRotate180 = server.hasArg("oled_rotate_180"); - - if (!hasSleep && !hasHost && !hasVolume && !hasWelcome && !hasSerialLog && - !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\"}" - ); - return; - } - - uint32_t sleepTimeoutMin = 0; - const uint32_t* sleepPtr = nullptr; - String hostnameArg; - const char* hostPtr = nullptr; - uint8_t volume = 0; - const uint8_t* volumePtr = nullptr; - bool welcome = false; - const bool* welcomePtr = nullptr; - bool serialLog = false; - const bool* serialLogPtr = nullptr; - uint32_t continuousTimeoutMin = 0; - const uint32_t* contToPtr = nullptr; - String loadingArg; - const char* loadingPtr = nullptr; - String eyesStyleArg; - const char* eyesStylePtr = nullptr; - String accessTokenArg; - const char* accessTokenPtr = nullptr; - uint8_t servoMins[SETTINGS_SERVO_COUNT] = {}; - uint8_t servoMaxs[SETTINGS_SERVO_COUNT] = {}; - const uint8_t* servoMinsPtr = nullptr; - const uint8_t* servoMaxsPtr = nullptr; - String rgbOrderArg; - const char* rgbOrderPtr = nullptr; - bool oledRotate180 = false; - const bool* oledRotate180Ptr = nullptr; - - if (hasSleep) { - const String sleepArg = server.arg("sleep_timeout"); - char* end = nullptr; - const unsigned long parsed = - strtoul(sleepArg.c_str(), &end, 10); - - if (end == sleepArg.c_str() || *end != '\0') { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid sleep_timeout\"}" - ); - return; - } - - sleepTimeoutMin = static_cast(parsed); - - if (!settingsValidateSleepTimeout(sleepTimeoutMin)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"sleep_timeout out of range\"}" - ); - return; - } - - sleepPtr = &sleepTimeoutMin; - } - - if (hasHost) { - hostnameArg = server.arg("hostname"); - hostPtr = hostnameArg.c_str(); - - if (!settingsValidateHostname(hostPtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid hostname\"}" - ); - return; - } - } - - if (hasVolume) { - const String volumeArg = server.arg("volume"); - char* end = nullptr; - const unsigned long parsed = - strtoul(volumeArg.c_str(), &end, 10); - - if (end == volumeArg.c_str() || *end != '\0' || parsed > 255) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid volume\"}" - ); - return; - } - - volume = static_cast(parsed); - - if (!settingsValidateVolume(volume)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"volume out of range\"}" - ); - return; - } - - volumePtr = &volume; - } - - if (hasWelcome) { - const String welcomeArg = server.arg("welcome"); - char* end = nullptr; - const unsigned long parsed = - strtoul(welcomeArg.c_str(), &end, 10); - - if (end == welcomeArg.c_str() || *end != '\0' || parsed > 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid welcome\"}" - ); - return; - } - - welcome = parsed == 1; - welcomePtr = &welcome; - } - - if (hasSerialLog) { - const String serialLogArg = server.arg("serial_log"); - char* end = nullptr; - const unsigned long parsed = - strtoul(serialLogArg.c_str(), &end, 10); - - if (end == serialLogArg.c_str() || *end != '\0' || parsed > 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid serial_log\"}" - ); - return; - } - - serialLog = parsed == 1; - serialLogPtr = &serialLog; - } - - if (hasContTo) { - const String contArg = server.arg("continuous_timeout"); - char* end = nullptr; - const unsigned long parsed = - strtoul(contArg.c_str(), &end, 10); - - if (end == contArg.c_str() || *end != '\0') { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid continuous_timeout\"}" - ); - return; - } - - continuousTimeoutMin = static_cast(parsed); - - if (!settingsValidateContinuousTimeout(continuousTimeoutMin)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"continuous_timeout out of range\"}" - ); - return; - } - - contToPtr = &continuousTimeoutMin; - } - - if (hasLoading) { - loadingArg = server.arg("loading"); - loadingPtr = loadingArg.c_str(); - - if (!settingsValidateLoading(loadingPtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid loading\"}" - ); - return; - } - } - - if (hasEyesStyle) { - eyesStyleArg = server.arg("eyes_style"); - eyesStylePtr = eyesStyleArg.c_str(); + JsonDocument body; - if (!settingsValidateEyesStyle(eyesStylePtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid eyes_style\"}" - ); - return; - } + if (!readJsonBody(server, body)) { + return; } - if (hasAccessToken) { - accessTokenArg = server.arg("access_token"); - accessTokenPtr = accessTokenArg.c_str(); - - if (!settingsValidateAccessToken(accessTokenPtr)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid access_token\"}" - ); - return; - } + if (body.size() == 0) { + sendError(server, 400, "send at least one setting"); + return; } - if (hasServoMins || hasServoMaxs) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"servo setup only in AP mode\"}" - ); - return; - } - - if (!hasServoMins || !hasServoMaxs) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"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\"}" - ); - return; - } + SettingsPatch patch; + String error = readPatch(body.as(), patch); - if (!settingsValidateServoRanges(servoMins, servoMaxs)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"servo ranges out of range\"}" - ); - return; - } - - servoMinsPtr = servoMins; - servoMaxsPtr = servoMaxs; + if (error.isEmpty() && patch.invalidField() != nullptr) { + error = String("invalid ") + patch.invalidField(); } - if (hasRgbOrder) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"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\"}" - ); - return; - } - - rgbOrderPtr = rgbOrderArg.c_str(); + if (!error.isEmpty()) { + sendError(server, 400, error.c_str()); + return; } - if (hasOledRotate180) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"oled setup only in AP mode\"}" - ); - return; - } - - const String oledArg = server.arg("oled_rotate_180"); - char* end = nullptr; - const unsigned long parsed = - 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\"}" - ); - return; - } + const char* setupStep = patch.setupOnlyStep(); - oledRotate180 = parsed == 1; - oledRotate180Ptr = &oledRotate180; + if (setupStep != nullptr && !wifiProvisioningMode()) { + sendError(server, 400, (String(setupStep) + " setup only in AP mode").c_str()); + return; } - bool rebootRequired = false; - - if (!saveSettings( - sleepPtr, - hostPtr, - volumePtr, - welcomePtr, - serialLogPtr, - contToPtr, - loadingPtr, - eyesStylePtr, - accessTokenPtr, - servoMinsPtr, - servoMaxsPtr, - rgbOrderPtr, - oledRotate180Ptr, - &rebootRequired - )) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"save failed\"}" - ); + if (!saveSettings(patch)) { + sendError(server, 400, "save failed"); return; } - refreshMdnsHostname(); - - if (oledRotate180Ptr != nullptr) { + if (patch.oledRotate180) { applyOledRotation(); restoreProvisioningOled(); } - sendSettingsJson(server, rebootRequired, false); + sendJson(server, 200, settingsJson()); } void handleSettingsWifiPut(WebServer& server) { JsonDocument body; - if (deserializeJson(body, server.arg("plain")) || - !body.is()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"body must be a JSON array of networks\"}" - ); + if (!readJsonBody(server, body)) { return; } @@ -488,11 +203,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 +217,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 +226,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 +242,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,15 +251,19 @@ void handleSettingsWifiPut(WebServer& server) { wifiRestoreProvisioningAp(); } - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"save failed\"}" - ); + sendError(server, 400, "save failed"); return; } - sendSettingsJson(server, false, tested); + JsonDocument reply = settingsJson(); + + if (tested) { + reply["wifi_connect_success"] = true; + reply["wifi_ip"] = wifiIpText(); + reply["wifi_hostname"] = wifiMdnsHostname(); + } + + sendJson(server, 200, reply); if (tested) { delay(300); @@ -577,23 +273,10 @@ void handleSettingsWifiPut(WebServer& server) { } void handleSettingsReset(WebServer& server) { - bool rebootRequired = false; - - if (!factoryResetSettings(&rebootRequired)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"factory reset failed\"}" - ); + if (!factoryResetSettings()) { + 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"; + sendJson(server, 200, settingsJson()); } 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..a6e47c68 100644 --- a/src/http/setup_handlers.cpp +++ b/src/http/setup_handlers.cpp @@ -1,392 +1,165 @@ -#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 "network/wifi_connect.h" -#include "servos.h" +#include "hardware/pca9685_servos.h" +#include "hardware/rgb.h" +#include "http/handlers.h" +#include "http/request.h" +#include "http/response.h" #include "settings/settings.h" namespace { -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; +JsonDocument setupReply(const char* step) { + JsonDocument reply; + reply["ok"] = true; + reply["setup"] = step; + return reply; } -bool isValidFloatArg(const String& s) { - if (s.length() == 0) { - return false; - } - - unsigned i = 0; +void holdLogicalColor(WebServer& server, JsonObjectConst body) { + const String color = body["color"].as(); + const JsonVariantConst order = body["rgb_order"]; - if (s[0] == '+' || s[0] == '-') { - i = 1; - if (i >= s.length()) { - return false; - } + if (color != "R" && color != "G" && color != "B") { + sendError(server, 400, "invalid color"); + return; } - 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; + if (!order.isNull() && !settingsValidateRgbOrder(order.as())) { + sendError(server, 400, "invalid rgb_order"); + return; } - return sawDigit; + rgbSetupHoldLogical( + color == "R" ? 255 : 0, + color == "G" ? 255 : 0, + color == "B" ? 255 : 0, + order.as() + ); + + JsonDocument reply = setupReply("led"); + reply["color"] = color; + sendJson(server, 200, reply); } -void handleSetupServo(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup servo only in AP mode\"}" - ); +void holdWireByte(WebServer& server, JsonVariantConst byte) { + if (!byte.is() || byte.as() > 2) { + sendError(server, 400, "invalid byte"); return; } - if (server.hasArg("all")) { - if (server.arg("all") != "90") { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid all\"}" - ); - return; - } + rgbSetupHoldWireByte(byte.as()); - servoMoveAllToElectricalAngle(90.0f); - httpSendJson(server, 200, "{\"ok\":true,\"setup\":\"servo\",\"all\":90}"); - return; - } + JsonDocument reply = setupReply("led"); + reply["byte"] = byte; + sendJson(server, 200, reply); +} - if (!server.hasArg("index") || !server.hasArg("angle")) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"missing index or angle\"}" - ); - return; - } +} // namespace - const String indexArg = server.arg("index"); - const String angleArg = server.arg("angle"); +void handleSetupServo(WebServer& server) { + JsonDocument body; - if (!isDigitsOnly(indexArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid index\"}" - ); + if (!readJsonBody(server, body)) { return; } - if (!isValidFloatArg(angleArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid angle\"}" - ); - return; - } + if (!body["all"].isNull()) { + if (body["all"] != 90) { + sendError(server, 400, "invalid all"); + return; + } - const int index = indexArg.toInt(); - const float angle = angleArg.toFloat(); + servoMoveAllToElectricalAngle(90.0f); - if (index < 0 || index >= SERVO_COUNT) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); + JsonDocument reply = setupReply("servo"); + reply["all"] = 90; + sendJson(server, 200, reply); return; } - if (angle < 0.0f || angle > 180.0f) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"angle out of range\"}" - ); - return; - } + const std::optional move = ServoMove::read(server, body.as()); - if (!moveServoSmoothElectrical(index, angle)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); + if (!move) { return; } - char body[96]; + moveServoSmoothElectrical(move->index, move->angle); - snprintf( - body, - sizeof(body), - "{\"ok\":true,\"setup\":\"servo\",\"index\":%d,\"angle\":%g}", - index, - (double)angle - ); - - httpSendJson(server, 200, body); + JsonDocument reply = setupReply("servo"); + move->addTo(reply); + sendJson(server, 200, reply); } void handleSetupLed(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup led only in AP mode\"}" - ); - return; - } + JsonDocument body; - 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\"}" - ); + if (!readJsonBody(server, body)) { return; } - 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\"}" - ); - 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]; + const bool hasColor = !body["color"].isNull(); + const bool hasByte = !body["byte"].isNull(); - 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}"); + if (hasColor && hasByte) { + sendError(server, 400, "color and byte conflict"); return; } - if (!isDigitsOnly(byteArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid byte\"}" - ); + if (hasColor) { + holdLogicalColor(server, body.as()); return; } - const int byteIndex = byteArg.toInt(); - - if (byteIndex < 0 || byteIndex > 2) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"byte out of range\"}" - ); + if (hasByte) { + holdWireByte(server, body["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 reply = setupReply("led"); + reply["off"] = true; + sendJson(server, 200, reply); } void handleSetupAudio(WebServer& server) { - if (!wifiProvisioningMode()) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"setup audio only in AP mode\"}" - ); - return; - } + const bool played = playWelcome(); + showIdleScreen(); - if (!playWelcome()) { - showIdleScreen(); - httpSendJson( - server, - 500, - "{\"ok\":false,\"error\":\"welcome playback failed\"}" - ); + 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\"}" - ); - return; - } + JsonDocument body; - if (!server.hasArg("rotate_180")) { - restoreProvisioningOled(); - httpSendJson( - server, - 200, - "{\"ok\":true,\"setup\":\"oled\",\"restored\":true}" - ); + if (!readJsonBody(server, body)) { return; } - const String rotArg = server.arg("rotate_180"); + const JsonVariantConst rotate = body["rotate_180"]; + JsonDocument reply = setupReply("oled"); - if (!isDigitsOnly(rotArg)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid rotate_180\"}" - ); + if (rotate.isNull()) { + restoreProvisioningOled(); + reply["restored"] = true; + sendJson(server, 200, reply); return; } - const int parsed = rotArg.toInt(); - - if (parsed != 0 && parsed != 1) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"invalid rotate_180\"}" - ); + if (!rotate.is()) { + 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"; -} - -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); } - ); + showOledOrientationTest(rotate.as()); + reply["rotate_180"] = rotate; + sendJson(server, 200, reply); } 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..8f38580c 100644 --- a/src/http/test_handlers.cpp +++ b/src/http/test_handlers.cpp @@ -1,227 +1,79 @@ -#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() { +JsonDocument testReply(const char* test) { + JsonDocument reply; + reply["ok"] = true; + reply["test"] = test; + return reply; +} + +// Every test leaves the OLED on its idle screen before it answers. +void finishTest(WebServer& server, const JsonDocument& reply) { showIdleScreen(); + sendJson(server, 200, reply); } +} // namespace + void handleAudioTest(WebServer& server) { runSoundTest(); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"audio\"}"); + finishTest(server, testReply("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, testReply("bell")); } void handleScreenTest(WebServer& server) { runOledTest(); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"screen\"}"); + finishTest(server, testReply("screen")); } void handleMovementTest(WebServer& server) { runServoTest(); - restoreReadyScreen(); - httpSendJson(server, 200, "{\"ok\":true,\"test\":\"movement\"}"); + finishTest(server, testReply("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, testReply("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; - } + JsonDocument request; - 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 (!readJsonBody(server, request)) { return; } - if (angle < 0.0f || angle > 180.0f) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"angle out of range\"}" - ); - return; - } + const std::optional move = ServoMove::read(server, request.as()); - if (!moveServoSmooth(index, angle)) { - httpSendJson( - server, - 400, - "{\"ok\":false,\"error\":\"index out of range\"}" - ); + if (!move) { 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"; -} + moveServoSmooth(move->index, move->angle); -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 reply = testReply("servo"); + move->addTo(reply); + finishTest(server, reply); } 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" diff --git a/src/network/wifi_connect.cpp b/src/network/wifi_connect.cpp index d5e63a23..93548855 100644 --- a/src/network/wifi_connect.cpp +++ b/src/network/wifi_connect.cpp @@ -278,6 +278,10 @@ const char* wifiIpText() { return ipText; } +String wifiMdnsHostname() { + return String(settingsHostname()) + ".local"; +} + const char* wifiApSsid() { return apSsid; } diff --git a/src/network/wifi_connect.h b/src/network/wifi_connect.h index f69c65af..5f489421 100644 --- a/src/network/wifi_connect.h +++ b/src/network/wifi_connect.h @@ -1,10 +1,14 @@ #pragma once +#include + #include bool wifiConnected(); bool wifiProvisioningMode(); const char* wifiIpText(); +// ".local". +String wifiMdnsHostname(); const char* wifiApSsid(); const char* wifiApIpText(); void runWifiSetup(); 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..463c3ddd 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,51 +8,47 @@ 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; -} - -const char* settingsBootHostname() { - return g_bootHostname; + return g_settings.hostname; } 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 +64,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 +72,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..6c3dec98 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 { @@ -29,7 +25,7 @@ bool loadWifiNetworks() { } void migrateLegacyWifi() { - if (!settingsNvsBeginWrite()) { + if (!g_settingsPrefs.begin(kNs, false)) { return; } @@ -45,132 +41,29 @@ void migrateLegacyWifi() { g_settingsPrefs.remove(kKeyWifiPass); } - settingsNvsEnd(); + g_settingsPrefs.end(); } } // 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); + bool legacyWifi = false; - if (settingsValidateHostname(host.c_str())) { - setHostnameCache(g_hostname, host.c_str()); + if (g_settingsPrefs.begin(kNs, true)) { + g_settings.load(); + legacyWifi = loadWifiNetworks(); + g_settingsPrefs.end(); } 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; - } - - 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()); - } 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); + g_bootSettings = g_settings; if (legacyWifi) { migrateLegacyWifi(); } - logSettingsSnapshot("Settings:"); + g_settings.log("Settings:"); } diff --git a/src/settings/nvs.cpp b/src/settings/nvs.cpp index b3b503c1..647118bc 100644 --- a/src/settings/nvs.cpp +++ b/src/settings/nvs.cpp @@ -1,158 +1,213 @@ #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 { + +// 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())) { + strlcpy(dest, value.c_str(), sizeof(dest)); } } -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]; +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; } } -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]; +} // namespace + +void SettingsValues::load() { + loadMinutes(sleepTimeoutMin, kKeySleep, settingsValidateSleepTimeout); + loadText(hostname, kKeyHost, settingsValidateHostname); + + const uint32_t storedVolume = g_settingsPrefs.getUInt(kKeyVolume, volume); + + if (storedVolume <= UINT8_MAX && settingsValidateVolume(storedVolume)) { + 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); } -bool settingsNvsBeginRead() { - return g_settingsPrefs.begin(kNs, true); +namespace { + +bool openForWrite() { + if (g_settingsPrefs.begin(kNs, false)) { + return true; + } + + serialLogPrintln("Settings: NVS write open failed"); + return false; } -bool settingsNvsBeginWrite() { - return g_settingsPrefs.begin(kNs, false); +// Preferences commits every put on its own, so only keys whose value differs +// from `before` are written, unless `all` asks for every key. +bool writeValues(const SettingsValues& values, const SettingsValues& before, bool all) { + bool ok = true; + + auto putUInt = [&](const char* key, uint32_t value, uint32_t old) { + if (all || value != old) { + ok &= g_settingsPrefs.putUInt(key, value) == sizeof(uint32_t); + } + }; + auto putBool = [&](const char* key, bool value, bool old) { + if (all || value != old) { + ok &= g_settingsPrefs.putBool(key, value) == sizeof(uint8_t); + } + }; + auto putText = [&](const char* key, const char* value, const char* old) { + if (all || strcmp(value, old) != 0) { + ok &= g_settingsPrefs.putString(key, value) == strlen(value); + } + }; + + putUInt(kKeySleep, values.sleepTimeoutMin, before.sleepTimeoutMin); + putText(kKeyHost, values.hostname, before.hostname); + putUInt(kKeyVolume, values.volume, before.volume); + putBool(kKeyWelcome, values.welcome, before.welcome); + putBool(kKeySerialLog, values.serialLog, before.serialLog); + putUInt(kKeyContTo, values.continuousTimeoutMin, before.continuousTimeoutMin); + putText(kKeyLoading, values.loading, before.loading); + putText(kKeyEyesStyle, values.eyesStyle, before.eyesStyle); + putText(kKeyAccessTok, values.accessToken, before.accessToken); + putText(kKeyRgbOrder, values.rgbOrder, before.rgbOrder); + putBool(kKeyOledRot, values.oledRotate180, before.oledRotate180); + + if (all || memcmp(&values.servoRanges, &before.servoRanges, sizeof(ServoRanges)) != 0) { + uint8_t blob[kServoRangesLen]; + + for (size_t i = 0; i < SETTINGS_SERVO_COUNT; i++) { + blob[i * 2] = values.servoRanges.mins[i]; + blob[i * 2 + 1] = values.servoRanges.maxs[i]; + } + + ok &= g_settingsPrefs.putBytes(kKeyServoRanges, blob, kServoRangesLen) == kServoRangesLen; + } + + return ok; } -void settingsNvsEnd() { +} // namespace + +bool SettingsValues::save(const SettingsValues& saved) const { + if (!openForWrite()) { + return false; + } + + const bool ok = writeValues(*this, saved, false); g_settingsPrefs.end(); + return ok; } -void settingsNvsClear() { +bool SettingsValues::replaceAll() const { + if (!openForWrite()) { + return false; + } + g_settingsPrefs.clear(); + const bool ok = writeValues(*this, *this, true); + g_settingsPrefs.end(); + return ok; } -namespace { +bool saveSettings(const SettingsPatch& patch) { + if (patch.invalidField() != nullptr) { + return false; + } + + SettingsValues next = g_settings; + next.apply(patch); + + if (!next.save(g_settings)) { + return false; + } + + g_settings = next; + g_settings.log("Settings saved:"); + return true; +} -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); - - g_settingsPrefs.putUInt(kKeySleep, sleepTimeoutMin); - g_settingsPrefs.putString(kKeyHost, hostname); - g_settingsPrefs.putUInt(kKeyVolume, volume); - g_settingsPrefs.putBool(kKeyWelcome, welcome); - g_settingsPrefs.putBool(kKeySerialLog, serialLog); - g_settingsPrefs.putUInt(kKeyContTo, continuousTimeoutMin); - g_settingsPrefs.putString(kKeyLoading, loading); - g_settingsPrefs.putString(kKeyEyesStyle, eyesStyle); - g_settingsPrefs.putString(kKeyAccessTok, accessToken); - g_settingsPrefs.putBytes(kKeyServoRanges, servoBlob, kServoRangesLen); - g_settingsPrefs.putString(kKeyRgbOrder, rgbOrder); - g_settingsPrefs.putBool(kKeyOledRot, oledRotate180); +bool saveWifiNetworks(const WifiNetworkList& networks) { + if (!openForWrite()) { + return false; + } + + const size_t length = networks.count() * sizeof(WifiNetwork); + // Preferences writes nothing for an empty blob, so an empty list removes the key. + const bool written = length == 0 + ? !g_settingsPrefs.isKey(kKeyWifiNets) || g_settingsPrefs.remove(kKeyWifiNets) + : g_settingsPrefs.putBytes(kKeyWifiNets, networks.data(), length) == length; + + g_settingsPrefs.end(); + + if (!written) { + serialLogPrintln("Settings: WiFi networks write failed"); + return false; + } + + g_wifiNetworks = networks; + g_wifiChangedSinceBoot = true; + g_settings.log("Settings saved:"); + return true; } -} // namespace +bool factoryResetSettings() { + // The hardware calibration survives a reset. + SettingsValues next = SettingsValues::defaults(); + next.servoRanges = g_settings.servoRanges; + strlcpy(next.rgbOrder, g_settings.rgbOrder, sizeof(next.rgbOrder)); + next.oledRotate180 = g_settings.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 -) { - if (!settingsNvsBeginWrite()) { - serialLogPrintln("Settings: NVS write open failed"); + if (!next.replaceAll()) { + serialLogPrintln("Settings: factory reset failed"); return false; } - putAll( - sleepTimeoutMin, - hostname, - volume, - welcome, - serialLog, - continuousTimeoutMin, - loading, - eyesStyle, - accessToken, - servoMins, - servoMaxs, - rgbOrder, - oledRotate180 - ); - settingsNvsEnd(); + g_settings = next; + g_wifiChangedSinceBoot |= g_wifiNetworks.count() > 0; + g_wifiNetworks.clear(); + g_settings.log("Settings factory reset:"); 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 settingsRebootRequired() { + return strcmp(g_settings.hostname, g_bootSettings.hostname) != 0 || + strcmp(g_settings.loading, g_bootSettings.loading) != 0 || + g_wifiChangedSinceBoot; } diff --git a/src/settings/nvs.h b/src/settings/nvs.h index 8f558a34..1fc3d5c0 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,5 @@ 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..aa27fe27 100644 --- a/src/settings/settings.h +++ b/src/settings/settings.h @@ -2,6 +2,7 @@ #include #include +#include class WifiNetworkList; @@ -24,7 +25,6 @@ constexpr size_t SETTINGS_ACCESS_TOKEN_MAX_LEN = 64; constexpr size_t SETTINGS_WIFI_SSID_MAX_LEN = 32; constexpr size_t SETTINGS_WIFI_PASSWORD_MAX_LEN = 63; constexpr size_t SETTINGS_WIFI_NETWORK_MAX = 5; -constexpr uint8_t SETTINGS_VOLUME_MIN = 0; constexpr uint8_t SETTINGS_VOLUME_MAX = 100; constexpr uint32_t SETTINGS_CONTINUOUS_TIMEOUT_MIN_MIN = 1; constexpr uint32_t SETTINGS_CONTINUOUS_TIMEOUT_MAX_MIN = 1440; @@ -34,6 +34,37 @@ 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; + + // Name of the setup wizard step that owns a field the patch sets (servo + // ranges, RGB order, screen rotation), or nullptr. Those save only in setup AP mode. + const char* setupOnlyStep() const; +}; + void initSettings(); uint32_t settingsSleepTimeoutMin(); @@ -54,8 +85,9 @@ float settingsServoMax(int index); const char* settingsRgbOrder(); bool settingsOledRotate180(); -// Hostname used at boot for Wi-Fi/mDNS (frozen after initSettings). -const char* settingsBootHostname(); +// True once the saved hostname, loading screen or WiFi networks differ from +// what this boot started with; those apply only after a reboot. +bool settingsRebootRequired(); bool settingsValidateSleepTimeout(uint32_t sleepTimeoutMin); bool settingsValidateHostname(const char* hostname); @@ -66,38 +98,15 @@ 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. +bool saveSettings(const SettingsPatch& patch); // Replaces every saved network in RAM and NVS. bool saveWifiNetworks(const WifiNetworkList& networks); -// Clears NVS namespace and restores settings to defaults except servo -// min/max, RGB LED byte order, and OLED 180° rotation, which stay as -// currently saved. -// *rebootRequired is set when boot hostname or loading differed from -// defaults before reset. -bool factoryResetSettings(bool* rebootRequired); +// Clears the NVS namespace and restores the defaults, WiFi networks included, +// but keeps the servo min/max, RGB LED byte order and OLED 180° rotation. +bool factoryResetSettings(); diff --git a/src/settings/validate.cpp b/src/settings/validate.cpp index d0b97f20..94b33042 100644 --- a/src/settings/validate.cpp +++ b/src/settings/validate.cpp @@ -73,6 +73,12 @@ bool settingsValidateAccessToken(const char* accessToken) { return false; } + // HTTP clients and the server trim header values, so a token that starts or + // ends with a space could never match the Authorization header. + if (len > 0 && (accessToken[0] == ' ' || accessToken[len - 1] == ' ')) { + return false; + } + for (size_t i = 0; i < len; i++) { const unsigned char c = static_cast(accessToken[i]); @@ -103,21 +109,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 +131,59 @@ 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; +} + +const char* SettingsPatch::setupOnlyStep() const { + if (servoRanges) { + return "servo"; + } + + if (rgbOrder != nullptr) { + return "rgb"; + } + + if (oledRotate180) { + return "oled"; + } + + return nullptr; +} diff --git a/src/settings/values.cpp b/src/settings/values.cpp new file mode 100644 index 00000000..0f69882e --- /dev/null +++ b/src/settings/values.cpp @@ -0,0 +1,90 @@ +#include "settings/values.h" + +#include "serial_log.h" +#include "servos.h" + +SettingsValues g_settings = SettingsValues::defaults(); +WifiNetworkList g_wifiNetworks; +SettingsValues g_bootSettings = g_settings; +bool g_wifiChangedSinceBoot = false; + +SettingsValues SettingsValues::defaults() { + SettingsValues values{}; + + values.sleepTimeoutMin = SETTINGS_DEFAULT_SLEEP_TIMEOUT_MIN; + strlcpy(values.hostname, SETTINGS_DEFAULT_HOSTNAME, sizeof(values.hostname)); + values.volume = SETTINGS_DEFAULT_VOLUME; + values.welcome = SETTINGS_DEFAULT_WELCOME; + values.serialLog = SETTINGS_DEFAULT_SERIAL_LOG; + values.continuousTimeoutMin = SETTINGS_DEFAULT_CONTINUOUS_TIMEOUT_MIN; + strlcpy(values.loading, SETTINGS_DEFAULT_LOADING, sizeof(values.loading)); + strlcpy(values.eyesStyle, SETTINGS_DEFAULT_EYES_STYLE, sizeof(values.eyesStyle)); + strlcpy(values.accessToken, SETTINGS_DEFAULT_ACCESS_TOKEN, sizeof(values.accessToken)); + + 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); + } + + strlcpy(values.rgbOrder, SETTINGS_DEFAULT_RGB_ORDER, sizeof(values.rgbOrder)); + 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) { + strlcpy(hostname, patch.hostname, sizeof(hostname)); + } + + if (patch.loading != nullptr) { + strlcpy(loading, patch.loading, sizeof(loading)); + } + + if (patch.eyesStyle != nullptr) { + strlcpy(eyesStyle, patch.eyesStyle, sizeof(eyesStyle)); + } + + if (patch.accessToken != nullptr) { + strlcpy(accessToken, patch.accessToken, sizeof(accessToken)); + } + + if (patch.rgbOrder != nullptr) { + strlcpy(rgbOrder, patch.rgbOrder, sizeof(rgbOrder)); + } +} + +void SettingsValues::log(const char* prefix) const { + if (!serialLog) { + return; + } + + const ServoRanges& r = servoRanges; + + Serial.printf( + "%s sleep_timeout=%umin hostname=%s volume=%u welcome=%s serial_log=on " + "continuous_timeout=%umin loading=%s eyes_style=%s access_token=%s wifi_networks=%u " + "servos=%u-%u;%u-%u;%u-%u;%u-%u;%u-%u rgb_order=%s oled_rotate_180=%s\n", + prefix, + static_cast(sleepTimeoutMin), + hostname, + volume, + welcome ? "on" : "off", + static_cast(continuousTimeoutMin), + loading, + eyesStyle, + accessToken[0] != '\0' ? "set" : "unset", + static_cast(g_wifiNetworks.count()), + r.mins[0], r.maxs[0], r.mins[1], r.maxs[1], r.mins[2], r.maxs[2], r.mins[3], r.maxs[3], + r.mins[4], r.maxs[4], + rgbOrder, + oledRotate180 ? "on" : "off" + ); +} diff --git a/src/settings/values.h b/src/settings/values.h new file mode 100644 index 00000000..ba61a27f --- /dev/null +++ b/src/settings/values.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +#include "settings/settings.h" +#include "settings/wifi_network_list.h" + +// 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 the keys that differ from `saved`, the values NVS holds now. + bool save(const SettingsValues& saved) const; + + // Erases the namespace, WiFi networks included, then writes every key. + bool replaceAll() const; + + void log(const char* prefix) const; +}; + +extern SettingsValues g_settings; +extern WifiNetworkList g_wifiNetworks; +// Hostname and loading screen are read at boot only; a change waits for a reboot. +extern SettingsValues g_bootSettings; +extern bool g_wifiChangedSinceBoot; diff --git a/test/test_settings_validate/test_settings_validate.cpp b/test/test_settings_validate/test_settings_validate.cpp index 0d8d0c5b..aae355f5 100644 --- a/test/test_settings_validate/test_settings_validate.cpp +++ b/test/test_settings_validate/test_settings_validate.cpp @@ -71,6 +71,9 @@ void test_access_token() { TEST_ASSERT_TRUE(settingsValidateAccessToken(token64)); TEST_ASSERT_FALSE(settingsValidateAccessToken(token65)); TEST_ASSERT_FALSE(settingsValidateAccessToken("foo\x01" "bar")); + TEST_ASSERT_TRUE(settingsValidateAccessToken("two words")); + TEST_ASSERT_FALSE(settingsValidateAccessToken(" leading")); + TEST_ASSERT_FALSE(settingsValidateAccessToken("trailing ")); TEST_ASSERT_FALSE(settingsValidateAccessToken(nullptr)); } @@ -106,18 +109,44 @@ 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_patch_names_its_setup_only_step() { + SettingsPatch patch; + + patch.volume = 50; + patch.hostname = "tiny-engineer"; + TEST_ASSERT_NULL(patch.setupOnlyStep()); + + patch.oledRotate180 = false; + TEST_ASSERT_EQUAL_STRING("oled", patch.setupOnlyStep()); + + patch.servoRanges = ServoRanges{{10, 20, 30, 40, 50}, {20, 30, 40, 50, 60}}; + TEST_ASSERT_EQUAL_STRING("servo", patch.setupOnlyStep()); } void test_rgb_order() { @@ -145,6 +174,8 @@ 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_patch_names_its_setup_only_step); RUN_TEST(test_rgb_order); return UNITY_END(); } 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..c52b6326 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -1,6 +1,11 @@ +import eslintReact from "@eslint-react/eslint-plugin"; import js from "@eslint/js"; import stylistic from "@stylistic/eslint-plugin"; +import { defineConfig } from "eslint/config"; +import promise from "eslint-plugin-promise"; +import reactHooks from "eslint-plugin-react-hooks"; import globals from "globals"; +import tseslint from "typescript-eslint"; const controlFlow = [ "if", @@ -15,12 +20,29 @@ const controlFlow = [ "continue", ]; -export default [ +export default defineConfig( { ignores: ["dist/"] }, - js.configs.recommended, { - plugins: { "@stylistic": stylistic }, - languageOptions: { globals: globals.browser }, + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, + eslintReact.configs["recommended-type-checked"], + reactHooks.configs.flat.recommended, + ], + plugins: { "@stylistic": stylistic, promise }, + languageOptions: { + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + settings: { + // Preact 10 matches the React 18 component API. + "react-x": { importSource: "preact", version: "18.3.1" }, + }, rules: { curly: ["error", "all"], eqeqeq: ["error", "always", { null: "ignore" }], @@ -29,6 +51,21 @@ export default [ "prefer-arrow-callback": "error", "prefer-const": "error", "prefer-template": "error", + // `onClick={() => setOpen(true)}` returns the setter's void on purpose. + "@typescript-eslint/no-confusing-void-expression": ["error", { ignoreArrowShorthand: true }], + "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], + // `text || fallback` treats an empty string as missing on purpose. + "@typescript-eslint/prefer-nullish-coalescing": [ + "error", + { ignorePrimitives: { string: true } }, + ], + // Event handlers may be async; Preact ignores what they return. + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], + // try/catch around await instead of .then/.catch chains. + "promise/prefer-await-to-then": ["error", { strict: true }], "@stylistic/padding-line-between-statements": [ "error", { blankLine: "always", prev: "*", next: controlFlow }, @@ -39,7 +76,11 @@ export default [ }, }, { - files: ["*.config.js"], + files: ["*.config.{js,ts}"], languageOptions: { globals: globals.node }, }, -]; + { + files: ["eslint.config.js"], + extends: [js.configs.recommended], + }, +); 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..5f82401a 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5,17 +5,29 @@ "packages": { "": { "name": "tiny-engineer-ui", + "dependencies": { + "clsx": "^2.1.1", + "preact": "~10.29.8", + "wouter-preact": "^3.13.0" + }, "devDependencies": { + "@eslint-react/eslint-plugin": "^5.26.0", "@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-promise": "^7.3.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 +84,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 +254,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", @@ -371,61 +693,102 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "node_modules/@eslint-react/ast": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-5.26.0.tgz", + "integrity": "sha512-vcPHFNQXNfXiI1zrlGiQkM6aPTizek9l5tEYIyOhGfs1YL4Wr8l8z3qQ6Wo71RItp/UOpQNu5KQg85nxDsSvVw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/typescript-estree": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "node_modules/@eslint-react/core": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-5.26.0.tgz", + "integrity": "sha512-b6SWHVrQv+GHwC3oGSzJgrAbN55pH3+Zmf6MoTLHM144T9Z6EqEdd9MjIDNmnDLehKHoSm9RwhSkTI/y//mZMg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/core": "^1.2.1" + "@eslint-react/ast": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "ts-pattern": "^5.9.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "node_modules/@eslint-react/eslint": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint/-/eslint-5.26.0.tgz", + "integrity": "sha512-x2Rx8pvYePUEkc1OQ6du4uL8MMLD7teSy2l6Q2IOi2M8tiSZ1GYjB4zI5VgRD1RnMU5EH/NMRUOzsMPP5j2h3Q==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "node_modules/@eslint-react/eslint-plugin": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint-plugin/-/eslint-plugin-5.26.0.tgz", + "integrity": "sha512-WzqJP/LGtRALkCnTk5NKfj20sJaVRyj5XkGMk18B4x1ONXI5XPmpWlMsfvs0MjXkvbdVe5F3ZvruQ8hUp4/iQg==", "dev": true, "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "dependencies": { + "@eslint-react/shared": "5.26.0", + "eslint-plugin-react-dom": "5.26.0", + "eslint-plugin-react-jsx": "5.26.0", + "eslint-plugin-react-naming-convention": "5.26.0", + "eslint-plugin-react-rsc": "5.26.0", + "eslint-plugin-react-web-api": "5.26.0", + "eslint-plugin-react-x": "5.26.0" }, - "funding": { - "url": "https://eslint.org/donate" + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { - "eslint": "^10.0.0" + "eslint": "*", + "typescript": "*" }, "peerDependenciesMeta": { "eslint": { @@ -433,16 +796,157 @@ } } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "node_modules/@eslint-react/jsx": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/jsx/-/jsx-5.26.0.tgz", + "integrity": "sha512-I6Zn+STKua/TIpfOUoaRZSt57JRufP6G4oywTqAEkEQuCKEGyMS1wnLWvKm4Zn1uVar5mbc7rEm56En1AyNU3w==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "ts-pattern": "^5.9.0" + }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - } - }, + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/shared": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-5.26.0.tgz", + "integrity": "sha512-hTorQMaxsigRdlfpG4l3xpkC3iOtQZEiyl0jSm9pugIz0NmEO1Qj79dHR/HqMvI26FbF89ZHqKeP3JpdRnjDxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/eslint": "5.26.0", + "@typescript-eslint/utils": "^8.71.1", + "ts-pattern": "^5.9.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/var": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-5.26.0.tgz", + "integrity": "sha512-JZXhRHffFLC12lBacQ1IMpDOzSCO1FbIt1s8kYJD2PHJtM5zfmEmBV7hxeUv51xxm873rpVFatCZXgBDmhvD9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@typescript-eslint/scope-manager": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@eslint/plugin-kit": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", @@ -475,16 +979,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 +1056,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 +1077,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,6 +1167,118 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "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", + "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/@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", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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", + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0" + } + }, + "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", + "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", @@ -953,6 +1559,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 +1654,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", @@ -1057,13 +1781,252 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/types": { + "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/types/-/types-8.71.1.tgz", - "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", "dev": true, "license": "MIT", - "engines": { + "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/@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", + "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/visitor-keys": { + "version": "8.71.1", + "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" }, "funding": { @@ -1185,6 +2148,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 +2196,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 +2232,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 +2255,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 +2288,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,48 +2336,144 @@ "qified": "^0.10.1" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "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", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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, + "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", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/camel-case": { + "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chai": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", - "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "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": ">=18" + "node": ">=8" } }, - "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/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/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/color-convert": { @@ -1343,15 +2503,19 @@ "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/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } + "license": "MIT" + }, + "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" }, "node_modules/cosmiconfig": { "version": "9.0.2", @@ -1405,6 +2569,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 +2600,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 +2665,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 +2705,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 +2762,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 +2890,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 +2938,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", @@ -1600,20 +3015,238 @@ "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, - "bin": { - "eslint": "bin/eslint.js" + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-promise": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.3.0.tgz", + "integrity": "sha512-6uGiOR0INuujr6PEQmeSSP7GbIMJ/ebEXXiEzb/nOj68LknH5Pxzb/AbZivmr6VE6TkTE8rTjRK9zhKpK6HsRA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-dom": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.26.0.tgz", + "integrity": "sha512-zEQSQ46ulLgfdbP2LrVw7sc6prTwewoZLLs+ujWXhjsZFqvnbOgXH5TojX1HCo9no1n/oB6UNhx/SB0hoQV9cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/jsx": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "compare-versions": "^6.1.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "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-plugin-react-jsx": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-5.26.0.tgz", + "integrity": "sha512-u6p2P+13fHV/GUkOQGVPBGDo29X36HEPd1f5QfP4woRoyzFxCtx/Q5aWzkeBS+C7eGNhhW9RsnaR1hjZCbgygQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/core": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/jsx": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-naming-convention": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-5.26.0.tgz", + "integrity": "sha512-phDoPg5w+y8KzXCaoB9gkHW4QpU8OKTVO6za/ODiXWg1cYmjH3uvyOgJfzUxuSCUV7mIa9zPOMftK/IkZyAIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/core": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-rsc": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-5.26.0.tgz", + "integrity": "sha512-0UXPnVakYWx2nLRws97HMp1rFoqubzongO4YOdcGSrSzSsZY8NE8PHiq7qqJ1Yfih2SaO3JfQaqm8bTtHOoAyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/core": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-web-api": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-5.26.0.tgz", + "integrity": "sha512-wPIpPAArln2NK/JbW3yCLnMYIXHrdlkav4G92nBcSbqJBwTifYZdwLRg3bAqJM7JImCHOUTgjFGT2EQczlHjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/core": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "ts-pattern": "^5.9.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=20.19.0" }, - "funding": { - "url": "https://eslint.org/donate" + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-x": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-5.26.0.tgz", + "integrity": "sha512-GnkzfvqsyyoBTgmDXiweq2nRMQGoK/9GgeKSH8WO5n1NSRrcel3xLQgu/sr6OuzSr2UXezUE9J45qsK+PdhLgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.26.0", + "@eslint-react/core": "5.26.0", + "@eslint-react/eslint": "5.26.0", + "@eslint-react/jsx": "5.26.0", + "@eslint-react/shared": "5.26.0", + "@eslint-react/var": "5.26.0", + "@typescript-eslint/scope-manager": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "@typescript-eslint/typescript-estree": "^8.71.1", + "@typescript-eslint/utils": "^8.71.1", + "compare-versions": "^6.1.1", + "string-ts": "^2.3.1", + "ts-api-utils": "^2.5.0", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=20.19.0" }, "peerDependencies": { - "jiti": "*" + "eslint": "*", + "typescript": "*" }, "peerDependenciesMeta": { - "jiti": { + "eslint": { "optional": true } } @@ -1899,6 +3532,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 +3563,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 +3606,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 +3751,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 +3790,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 +3845,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 +3905,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 +3918,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" + "node": ">=6" }, - "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" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "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": ">=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" }, - "peerDependenciesMeta": { - "@jest/globals": { - "optional": true - }, - "@vitest/expect": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-snapshot": { - "optional": true - }, - "vitest": { - "optional": true - } + "engines": { + "node": ">= 0.4" } }, - "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/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": "Apache-2.0", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": "^22.12 || >= 24.0" + "node": ">= 0.4" }, - "peerDependencies": { - "ajv": "^8.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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-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": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "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 +4126,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 +4149,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 +4186,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 +4383,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 +4417,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 +4450,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 +4774,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 +4784,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 +4804,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 +4931,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 +4962,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 +5100,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 +5158,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 +5198,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 +5285,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 +5329,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": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "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", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, "engines": { - "node": ">= 6" + "node": ">=8" } }, - "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-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": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/punycode": { @@ -3302,14 +5418,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 +5544,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 +5576,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 +5642,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 +5731,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 +5773,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 +5792,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 +5809,27 @@ "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-ts": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.3.1.tgz", + "integrity": "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", @@ -3806,32 +6097,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 +6193,25 @@ "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": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", "dev": true, - "license": "0BSD" + "license": "MIT" }, "node_modules/type-check": { "version": "0.4.0", @@ -3948,6 +6226,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 +6274,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 +6294,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 +6442,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 +6617,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 +6701,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 +6743,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 +6762,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..7c9ba08f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,22 +9,34 @@ "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-react/eslint-plugin": "^5.26.0", "@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-promise": "^7.3.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": { + "clsx": "^2.1.1", + "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..dccb94d9 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,65 @@ +import { Route, Switch } from "wouter-preact"; +import { Footer } from "./components/Footer"; +import { Nav } from "./components/Nav"; +import { StatusBar } from "./components/StatusBar"; +import { ConfigPage } from "./config/ConfigPage"; +import { AuthGate } from "./gates/AuthGate"; +import { RebootGate } from "./gates/RebootGate"; +import { RobotProvider, useRobot } from "./robot"; +import { StatusProvider } from "./status/status"; +import { Animations } from "./views/Animations"; +import { ApiReference } from "./views/ApiReference"; +import { Home } from "./views/Home"; +import { ServoControl } from "./views/ServoControl"; +import { Tests } from "./views/Tests"; + +function Shell() { + const { gate, inSetup } = useRobot(); + + if (gate === "booting") { + return null; + } + + if (gate === "auth") { + return ; + } + + if (gate === "reboot") { + return ; + } + + if (inSetup) { + return ( + <> + + + + ); + } + + return ( + <> +