diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 809a54f..ad83299 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,6 +5,10 @@ on: branches: - main - develop + pull_request: + branches: + - main + - develop jobs: build: @@ -16,25 +20,54 @@ jobs: - esp32-s3-mini steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - - uses: actions/cache@v3 + - uses: actions/cache@v5 with: path: | ~/.cache/pip ~/.platformio/.cache - key: ${{ runner.os }}-pio - - uses: actions/setup-node@v4.1.0 + key: ${{ runner.os }}-python-3.12-pio-${{ hashFiles('platformio.ini', 'miniweb/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-python-3.12-pio- + - uses: actions/setup-node@v6 + with: + node-version: "24" - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.12" - name: Build webUI - run: cd miniweb && npm i && npm run build; + run: cd miniweb && npm ci && npm run build - name: Install PlatformIO Core - run: pip install --upgrade platformio + run: | + python -m pip install --upgrade pip + python -m pip install "platformio==6.1.19" + + - name: Prime pioarduino Python environment + run: | + python -m venv ~/.platformio/penv + ~/.platformio/penv/bin/python -m pip install --upgrade pip + ~/.platformio/penv/bin/python -m pip install \ + "uv>=0.1.0" \ + "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip" \ + "littlefs-python>=0.16.0" \ + "fatfs-ng>=0.1.14" \ + "pyyaml>=6.0.2" \ + "rich-click>=1.8.6" \ + "zopfli>=0.2.2" \ + "intelhex>=2.3.0" \ + "rich>=14.0.0" \ + "urllib3<2" \ + "cryptography>=45.0.3" \ + "certifi>=2025.8.3" \ + "ecdsa>=0.19.1" \ + "bitstring>=4.3.1" \ + "reedsolo>=1.5.3,<1.8" \ + "esp-idf-size>=2.0.0" \ + "esp-coredump>=1.14.0" - name: Build PlatformIO Project run: | @@ -47,15 +80,16 @@ jobs: name: firmware-${{ matrix.board }} path: | .pio/build/${{ matrix.board }}/firmware.bin - .pio/build/${{ matrix.board }}/spiffs.bin + .pio/build/${{ matrix.board }}/littlefs.bin publish: + if: ${{ github.event_name == 'push' && github.repository == 'tadelv/yaeger' }} needs: build runs-on: ubuntu-latest permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/publish-firmware.yml b/.github/workflows/publish-firmware.yml index 1b97a1b..5c3d6b0 100644 --- a/.github/workflows/publish-firmware.yml +++ b/.github/workflows/publish-firmware.yml @@ -9,7 +9,7 @@ on: jobs: publish: - if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.repository.full_name == 'tadelv/yaeger' }} runs-on: ubuntu-latest permissions: contents: write @@ -68,7 +68,7 @@ jobs: done - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index a21e48a..8e75166 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -11,7 +11,7 @@ jobs: outputs: matrix: ${{ steps.collect.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Collect PlatformIO environments id: collect @@ -43,26 +43,55 @@ jobs: matrix: ${{ fromJson(needs.determine-boards.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/cache@v3 + - uses: actions/cache@v5 with: path: | ~/.cache/pip ~/.platformio/.cache - key: ${{ runner.os }}-pio + key: ${{ runner.os }}-python-3.12-pio-${{ hashFiles('platformio.ini', 'miniweb/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-python-3.12-pio- - - uses: actions/setup-node@v4.1.0 + - uses: actions/setup-node@v6 + with: + node-version: "24" - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: "3.12" - name: Build webUI - run: cd miniweb && npm i && npm run build + run: cd miniweb && npm ci && npm run build - name: Install PlatformIO Core - run: pip install --upgrade platformio + run: | + python -m pip install --upgrade pip + python -m pip install "platformio==6.1.19" + + - name: Prime pioarduino Python environment + run: | + python -m venv ~/.platformio/penv + ~/.platformio/penv/bin/python -m pip install --upgrade pip + ~/.platformio/penv/bin/python -m pip install \ + "uv>=0.1.0" \ + "https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip" \ + "littlefs-python>=0.16.0" \ + "fatfs-ng>=0.1.14" \ + "pyyaml>=6.0.2" \ + "rich-click>=1.8.6" \ + "zopfli>=0.2.2" \ + "intelhex>=2.3.0" \ + "rich>=14.0.0" \ + "urllib3<2" \ + "cryptography>=45.0.3" \ + "certifi>=2025.8.3" \ + "ecdsa>=0.19.1" \ + "bitstring>=4.3.1" \ + "reedsolo>=1.5.3,<1.8" \ + "esp-idf-size>=2.0.0" \ + "esp-coredump>=1.14.0" - name: Build PlatformIO Project run: | @@ -73,7 +102,7 @@ jobs: run: | mkdir -p release/${{ matrix.board }} cp .pio/build/${{ matrix.board }}/firmware.bin release/${{ matrix.board }}/firmware.bin - cp .pio/build/${{ matrix.board }}/spiffs.bin release/${{ matrix.board }}/spiffs.bin + cp .pio/build/${{ matrix.board }}/littlefs.bin release/${{ matrix.board }}/littlefs.bin - name: Upload firmware artifacts uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index ff06119..4ca4891 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,77 @@ You can also control Yaeger from its own web interface without an app. Just poin your home wifi, or `192.168.4.1` if Yaeger creates its own access point. ![yaeger webui](./assets/yaeger-webui.png) +The web UI now includes a **Version & Network Info** section that shows the Web UI version/build timestamp and device firmware/network details (mode, SSID, IP, hostname) so you can quickly check when the currently loaded build was last updated. + +### Frontend status + +- `miniweb` (TypeScript + Vite) is the **only supported** web UI in this repository. +- The old `webserver` Svelte/Rollup frontend and related legacy files have been removed. +- Project scripts and firmware asset packaging target `miniweb`. + #### Using Yaeger on the go If Yaeger can't connect to your preferred Wifi, it will create its own access point. Perfect for when out and about :grin: ## Build guide (WIP) +## What changed in this fork + +If you are reviewing this fork before opening a PR against `tadelv/yaeger`, here is the practical summary: + +* `miniweb` is now the canonical frontend (TypeScript + Vite). Legacy `webserver` content is gone. +* OTA uploads are now aligned with ElegantOTA (`/update`) and no longer depend on PlatformIO `espota`. +* A one-command OTA flow (`ota_update_all.sh`) now updates both LittleFS web assets and firmware in one run. +* OTA tooling is isolated in a local Python virtual environment (`.ota-venv`) to avoid polluting global Python installs. +* GitHub Actions build flow now supports PR validation and avoids publish failures on forks/non-upstream repos. + +## Installation / update flows + +There are now two recommended paths depending on how you connect to your board: + +### 1) USB flash (first-time install or recovery) + +Use this when the device is connected over USB serial: + +```bash +./build_and_flash.sh s3 +# or +./build_and_flash.sh s3-mini +``` + +What it does: +1. installs frontend dependencies with `npm ci`, +2. builds `miniweb`, +3. optionally erases flash, +4. uploads LittleFS (`buildfs` + `uploadfs`), +5. uploads firmware (`upload`). + +### 2) OTA update (already deployed device on network) + +Use this once the device is reachable over Wi-Fi and ElegantOTA is available: + +```bash +./ota_update_all.sh s3 +# or +./ota_update_all.sh s3-mini +``` + +What it does: +1. creates/reuses `.ota-venv`, +2. installs OTA dependencies in that venv (`platformio`, `littlefs-python`, `fatfs-ng`, `pyyaml`), +3. builds `miniweb`, +4. uploads LittleFS image over ElegantOTA, +5. uploads firmware over ElegantOTA. + +If your device requires OTA credentials, set: + +```bash +export YAEGER_OTA_USERNAME=admin +export YAEGER_OTA_PASSWORD='your-password' +``` + +(`YAEGER_OTA_USERNAME` defaults to `admin` if omitted.) + ### Schema ![schema](./schema/Schematic_Yaeger_2024-12-24.svg) @@ -76,6 +141,34 @@ Courtesy of [@dlisec](https://github.com/dlisec) A build script has been provided by [@matthew73210](https://github.com/matthew73210), so to get up and running on the ESP, just run `./build_and_flash.sh`. Make sure to read the comments in the script. But also in the platformio.ini and choose the right board +Yaeger OTA in this project is provided by the web-based ElegantOTA handler (`/update`) and not the PlatformIO `espota` +upload protocol. + +For VS Code + PlatformIO uploads via ElegantOTA, use one of these environments: + +* `esp32-s3-elegantota` +* `esp32-s3-mini-elegantota` + +These use a custom PlatformIO upload script that sends the built firmware to `http://yaeger.local/update` through the +same ElegantOTA mechanism used by the device web UI. + +For a **single-command OTA update of the whole project** (frontend files + firmware), run: + +```bash +./ota_update_all.sh s3 +# or +./ota_update_all.sh s3-mini +``` + +This builds `miniweb`, then runs OTA in two explicit steps: (1) upload LittleFS (`buildfs` + `uploadfs`) and (2) upload firmware (`upload`). The script creates and uses a local Python virtual environment (`.ota-venv`), installs required OTA dependencies (`platformio`, `littlefs-python`, `fatfs-ng`, `pyyaml`), and auto-retries if PlatformIO reports missing Python modules. + +For local frontend builds, use npm from `miniweb`: + +```bash +cd miniweb +npm ci +npm run build +``` ## Latest features @@ -83,6 +176,8 @@ ESP, just run `./build_and_flash.sh`. Make sure to read the comments in the scri PID temp follower, set the temperature setpoint and the PID controller will try and follow. You'll need to find your own PID values +A controller review with alternatives (including MPC/LQR and fan min/max envelope design) is available at `docs/control_strategy_review_2026-04-14.md` (current recommendation: ADRC as primary advanced controller, with an ADRC autotune workflow proposal). + ### Profile Still in the works, but there is now a profile follower, it follows a simple .json format. You can have a go at [Gaggiuino web profiler](https://matthew73210.github.io/Gaggiuino-web-profiler/) under the _pun_ "Yägermeister Mode" diff --git a/build_and_flash.sh b/build_and_flash.sh index 4082742..6ee97ee 100755 --- a/build_and_flash.sh +++ b/build_and_flash.sh @@ -31,26 +31,13 @@ fi echo "Using PlatformIO environment: $PIO_ENV" - -read -p "Choose frontend (r for reyaeger, empty for classic): " frontend - -if [ $frontend = 'r' ]; then - -echo "reyaeger download"; -curl -L https://github.com/RobTS/reyaeger/releases/latest/download/reyaeger.zip > reyaeger.zip -rm -rf data -mkdir data -unzip -d ./data ./reyaeger.zip - -else - -# Step 1: Navigate to the miniweb directory +# Step 1: Navigate to the primary frontend (miniweb) echo "Navigating to miniweb..." cd miniweb || { echo "miniweb folder not found!"; exit 1; } -# Step 2: Install dependencies -echo "Installing dependencies with npm..." -npm install || { echo "npm install failed!"; exit 1; } +# Step 2: Install dependencies with npm (standardized package manager) +echo "Installing dependencies with npm ci..." +npm ci || { echo "npm ci failed!"; exit 1; } # Step 3: Build the web assets echo "Building the web project..." @@ -58,9 +45,7 @@ npm run build || { echo "npm build failed!"; exit 1; } # Step 4: Return to the project root echo "Returning to the project root..." - cd .. || exit 1 -fi # Step 5: Erase the device memory (optional but recommended) echo "Erasing the device memory..." diff --git a/docs/code_analysis_2026-04-06.md b/docs/code_analysis_2026-04-06.md new file mode 100644 index 0000000..503516a --- /dev/null +++ b/docs/code_analysis_2026-04-06.md @@ -0,0 +1,212 @@ +# Yaeger codebase analysis (April 6, 2026) + +This is a high-level technical review of the firmware + web clients, with prioritized, up-to-date improvement proposals. + +## Scope reviewed + +- Firmware entrypoint and runtime loop (`src/main.cpp`) +- WebSocket command/control path (`src/CommandLoop.cpp`) +- REST API and Wi‑Fi credential flow (`src/api.cpp`, `src/wifi_setup.cpp`) +- Frontend app state, transport, and build setup (`miniweb`, `webserver`) +- Dependency freshness and security posture from package manager checks + +## Current strengths + +- Clear separation between firmware concerns: sensors, fan/heater control, API, and Wi‑Fi modules. +- Safety fallback exists when WebSocket clients disconnect (`updateConnectionSafety`) with cooldown fan behavior. +- OTA update path is already integrated with ElegantOTA and static content serving from LittleFS. +- A newer `miniweb` app exists (TypeScript + Vite) in parallel to legacy Svelte/Rollup webserver. + +## Key findings and prioritized improvements + +## 1) **Critical security hardening (do first)** + +### Implementation status (April 6, 2026 update) + +- ~~Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings.~~ ✅ Implemented (`/api/wifi` now requires `POST` JSON). +- ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ 🔄 Partially implemented. + - ~~per-device admin password or token stored in NVS,~~ ✅ Implemented for `/api/wifi` and OTA Basic Auth. + - ~~CSRF-resistant flow for browser UI.~~ ✅ Implemented (`X-Yaeger-CSRF` header validated for mutable REST writes). +- ~~Protect OTA route with credentials and rate-limiting/backoff.~~ ✅ Credentials and exponential backoff implemented (OTA upload tooling retries transient failures). +- ~~Add secure defaults in AP mode:~~ 🔄 Partially implemented. + - ~~WPA2/WPA3 AP passphrase (not open AP),~~ ✅ Implemented (password-protected AP). + - ~~setup-mode timeout window.~~ ✅ Implemented (AP setup timeout + restart). + +### Updated TODO list + +- [x] Replace `/api/wifi` GET query credential flow with authenticated `POST` + JSON body. +- [x] Protect OTA with admin credentials. +- [x] Enable AP passphrase and setup timeout window. +- [x] Add auth gate for WebSocket mutable control commands. +- [x] Add CSRF-resistant browser flow for authenticated actions. +- [x] Add rate limiting / exponential backoff for OTA endpoint. + +### Findings + +- ~~Wi‑Fi credentials are accepted over **HTTP GET query params** at `/api/wifi` (`ssid` / `pass`).~~ +- ~~API endpoints and OTA endpoint appear unauthenticated by default.~~ +- Device exposes AP fallback mode and local admin surface; risk is reduced with auth, CSRF controls, and OTA retry/backoff protections. + +### Recommendations (2026 best-practice) + +1. ~~Move `/api/wifi` to **POST + JSON body**; never pass passwords in URL query strings.~~ +2. ~~Add request authentication for mutable endpoints (`/api/wifi`, control commands, OTA), at minimum:~~ + - ~~per-device admin password or token stored in NVS,~~ + - ~~CSRF-resistant flow for browser UI.~~ +3. ~~Protect OTA route with credentials and add rate-limiting/backoff.~~ +4. Add secure defaults in AP mode: + - ~~WPA2/WPA3 AP passphrase (not open AP),~~ + - ~~setup-mode timeout window.~~ + +## 2) **WebSocket robustness and heap stability (high priority)** + +### Implementation status (April 6, 2026 update) + +- ~~Validate parse result (`DeserializationError`) and reject malformed frames.~~ ✅ Implemented (malformed JSON and unsupported fragmented/non-text frames are rejected). +- ~~Enforce command schema validation (required fields, ranges).~~ ✅ Implemented (numeric schema checks for mutating commands and preference payloads). +- ~~Replace fixed-size `char[200]` with `measureJson` + dynamic/streamed response.~~ ✅ Implemented (`measureJson` + dynamically-sized `String` response buffer). +- ~~Add clamp logic for actuator values (e.g., fan/heater range validation) server-side regardless of client behavior.~~ ✅ Implemented (server-side clamping + logging for burner/fan/cooldown values). + +### Updated TODO list + +- [x] Reject malformed JSON payloads and unsupported WebSocket frame shapes. +- [x] Validate command schema for mutating and preference commands. +- [x] Remove fixed-size WebSocket response buffer usage. +- [x] Clamp actuator and cooldown values server-side to safe ranges. + +### Findings + +- `deserializeJson` return value is not checked before consuming fields. +- Incoming frame handling concatenates payload into `String` and uses small fixed JSON capacity assumptions. +- Outgoing buffer is fixed `char buffer[200]`, risking truncation if payload grows. + +### Recommendations + +1. ~~Validate parse result (`DeserializationError`) and reject malformed frames.~~ +2. ~~Enforce command schema validation (required fields, ranges).~~ +3. ~~Replace fixed-size `char[200]` with `measureJson` + dynamic/streamed response.~~ +4. ~~Add clamp logic for actuator values (e.g., fan/heater range validation) server-side regardless of client behavior.~~ + +## 3) **Network resiliency and boot behavior (high priority)** + +### Implementation status (April 11, 2026 update) + +- ~~Convert Wi‑Fi connect to non-blocking state machine (or bounded async retry steps).~~ ✅ Implemented (connect attempts now run without blocking startup loop and time out to AP fallback). +- ~~Keep loop tick deterministic by moving periodic tasks to elapsed-time scheduling.~~ ✅ Implemented (`millis()`-driven 10ms fast tick for cleanup/safety/sensor polling). +- ~~Add watchdog-friendly design: avoid long blocking sections in startup/connect paths.~~ ✅ Implemented (removed blocking connect loop and replaced fixed loop delay with cooperative `yield()`). + +### Updated TODO list + +- [x] Replace blocking Wi‑Fi connect loop with non-blocking attempt tracking + timeout. +- [x] Move periodic runtime tasks to elapsed-time scheduling for deterministic ticks. +- [x] Remove fixed loop sleep and use cooperative yielding for watchdog friendliness. + +### Findings + +- Wi‑Fi connect routine blocks in a loop up to ~10s with `delay(1000)` retries. +- Main loop includes regular delays and mixed timing responsibilities. + +### Recommendations + +1. ~~Convert Wi‑Fi connect to non-blocking state machine (or bounded async retry steps).~~ +2. ~~Keep loop tick deterministic by moving periodic tasks to elapsed-time scheduling.~~ +3. ~~Add watchdog-friendly design: avoid long blocking sections in startup/connect paths.~~ + +## 4) **Frontend modernization path (high priority, medium effort)** + +### Implementation status (April 11, 2026 update) + +- ~~Make `miniweb` the single primary frontend and define deprecation timeline for `webserver`.~~ ✅ Implemented (project docs + build path now explicitly designate `miniweb` as primary and `webserver` as deprecated/frozen). +- ~~If legacy UI must remain, plan migration to modern Svelte/Vite stack.~~ ✅ Implemented as policy decision (legacy kept as frozen fallback with no new feature investment). +- ~~Standardize package manager/lockfile strategy (npm vs yarn) to reduce CI drift.~~ ✅ Implemented for active frontend path (`build_and_flash.sh` now uses `npm ci` for deterministic `miniweb` installs). + +### Updated TODO list + +- [x] Declare `miniweb` as canonical UI and deprecate legacy `webserver`. +- [x] Freeze legacy UI scope to maintenance-only fallback. +- [x] Standardize active frontend build path on deterministic npm installs. + +### Findings + +- Repository contains **two web UIs** (`webserver` legacy Svelte 3 + Rollup, and `miniweb` TypeScript + Vite). +- Legacy webserver dependency tree is significantly behind and has known advisory exposure via old Svelte line. + +### Recommendations + +1. ~~Make `miniweb` the single primary frontend and define deprecation timeline for `webserver`.~~ +2. ~~If legacy UI must remain, plan migration to modern Svelte/Vite stack.~~ +3. ~~Standardize package manager/lockfile strategy (npm vs yarn) to reduce CI drift.~~ + +## 5) **Dependency and supply-chain updates (high priority)** + +### Findings from `npm outdated` + +- `miniweb` has major updates pending (e.g., Vite 8.x, TypeScript 6.x, vite-plugin-pwa 1.x). +- `webserver` is heavily behind (Rollup 4.x, Svelte 5.x, SMUI 8.x available). +- root dependency `chartjs-plugin-trendline` also behind. + +### Findings from `npm audit` + +- `webserver` reports moderate vulnerabilities tied to old `svelte` line; major upgrade path available. + +### Recommendations + +1. Upgrade actively maintained UI (`miniweb`) first, one major at a time with CI snapshots. +2. Treat legacy `webserver` as frozen/deprecated or perform full migration sprint. +3. Add automated dependency checks (scheduled CI + Dependabot/Renovate). + +## 6) **API design and transport hygiene (medium priority)** + +### Findings + +- Control and data are mixed in loosely-typed WebSocket payloads. +- REST info endpoint is useful but minimal; no health/version compatibility contract. + +### Recommendations + +1. Version the protocol (`apiVersion`) across REST + WebSocket. +2. Introduce structured command envelopes and explicit error responses. +3. Add heartbeat/ping and reconnect backoff in frontend WebSocket client. + +## 7) **Build/test quality gates (medium priority)** + +### Findings + +- Frontend builds succeed, but there is no obvious unified CI matrix in repo root. +- Firmware static checks are configured in PlatformIO config but not validated in this environment (`pio` unavailable). + +### Recommendations + +1. Add CI pipeline matrix: + - firmware static analysis/build, + - miniweb build/lint/typecheck, + - optional legacy webserver build until sunset. +2. Add pre-merge checks for formatting + basic unit tests for pure logic modules. +3. Add release artifact version stamping for firmware + frontend and compatibility check. + +## Proposed implementation roadmap + +### Phase 1 (1-2 weeks): security + reliability + +- Migrate `/api/wifi` to authenticated POST. +- Add OTA auth + AP hardening defaults. +- Add WebSocket parse/validation/clamp guards. + +### Phase 2 (1-2 weeks): frontend consolidation + +- Define `miniweb` as primary. +- Freeze or retire `webserver`; remove dual-maintenance overhead. +- Upgrade `miniweb` core tooling with compatibility tests. + +### Phase 3 (ongoing): CI and observability + +- Introduce CI matrix and scheduled dependency scanning. +- Add structured logs + fault counters exposed via `/api/info` (or `/api/health`). +- Add smoke tests for profile run and actuator safety constraints. + +## Commands run for this analysis + +- `npm outdated --json` (repo root, `miniweb`, `webserver`) +- `npm run build` (`miniweb`, `webserver`) +- `npm audit --omit=dev --json` (`webserver`) +- `pio --version` (tool unavailable in environment) diff --git a/docs/control_strategy_review_2026-04-14.md b/docs/control_strategy_review_2026-04-14.md new file mode 100644 index 0000000..0c5e1a1 --- /dev/null +++ b/docs/control_strategy_review_2026-04-14.md @@ -0,0 +1,243 @@ +# Control Strategy Review (PID) and Alternatives + +Date: 2026-04-14 + +## Current PID implementation review + +The current firmware uses a single-loop PID that drives heater output only (`0..100%`) every 400 ms and smooths the output before commanding the heater SSR. The fan is not part of the closed-loop PID objective during normal operation. Instead, fan is controlled manually or by separate flows (autotune/delay measurement/manual safety). This is simple and robust, but it leaves roast quality and disturbance rejection potential on the table. + +### What is working well + +- Control update cadence and safety clamping are explicit (`PID_UPDATE_INTERVAL_MS`, actuator clamp `0..100`). +- Anti-windup exists via conditional integration and integral clamping. +- A process-delay predictor and delay measurement path already exist, which is a strong foundation for model-based control. +- Relay autotune and multiple tuning formulas are already supported. + +### Main control gaps + +1. **Single-input closed loop:** only heater is optimized by PID; fan is not coordinated in the control objective. +2. **No explicit multivariable constraints:** there is no built-in optimization that co-manages heater/fan tradeoffs while respecting user preferences (e.g., min/max fan envelope). +3. **Fixed-gain behavior across roast phases:** a single PID structure can struggle across drying/Maillard/development dynamics. +4. **No direct optimization of slope trajectories (RoR):** current loop tracks temperature, but profile slope can be more important for roasting consistency. + +## Recommended fan envelope feature (applies to all candidate controllers) + +Add user-configurable fan bounds and make *every* controller obey them. + +### Proposed new preferences and runtime fields + +- `controlFanMin` (0..100), default `30` +- `controlFanMax` (0..100), default `80` +- enforce `controlFanMin <= controlFanMax` (swap if reversed) + +### Actuator mapping rule + +Any controller computes raw commands: + +- `heaterRaw` in 0..100 +- `fanRaw` in 0..100 + +Then apply envelope: + +- `heater = clamp(heaterRaw, 0, 100)` +- `fan = clamp(fanRaw, controlFanMin, controlFanMax)` + +This gives users hard limits on airflow while still allowing automatic variation of fan. + +## Controller alternatives (6 options) + +Below are six options that can command **both heater and fan** while supporting user fan min/max bounds. + +### 1) Linear MPC (recommended long-term target) + +**What it is:** finite-horizon optimization on a linearized thermal model with constraints. + +**Why it fits this project:** +- Naturally handles two actuators (heater + fan). +- Explicitly enforces constraints (`heater 0..100`, `fan min..max`, rate limits). +- Can track temperature and RoR targets simultaneously. + +**Suggested objective:** +- Track bean temperature and/or ET targets. +- Penalize RoR error and actuator aggressiveness. +- Penalize fan movement to reduce noise/mechanical wear. + +**Complexity:** medium/high (requires model ID + QP solver or lightweight custom optimizer). + +### 2) LQR + integral action (LQI) + +**What it is:** state-feedback controller on linear model; add integral states for zero steady-state error. + +**Why it fits:** +- Lower compute load than MPC. +- Good multivariable coordination when model is decent. +- Stable and predictable tuning via Q/R matrices. + +**How to enforce fan limits:** +- Compute unconstrained command, then saturate and apply simple anti-windup logic. +- Optional reference governor to pre-shape commands so saturation is less frequent. + +**Complexity:** medium. + +### 3) Gain-scheduled 2x PID (heater PID + fan PID) + +**What it is:** keep PID family but use separate loops and phase-based gain schedules. + +**Why it fits:** +- Fastest migration path from current implementation. +- Familiar tuning workflow. +- Can use roast phase breakpoints (drying/Maillard/development) and temperature ranges. + +**Fan behavior:** +- Fan PID can regulate ET-BT delta, RoR damping, or smoke proxy. +- Always clamp by user `fanMin/fanMax`. + +**Complexity:** low/medium. + +### 4) ADRC (Active Disturbance Rejection Control) + +**What it is:** observer-based control that estimates unmodeled disturbances in real time. + +**Why it fits:** +- Handles disturbances (batch size variance, charge temp shifts, ambient changes) better than fixed PID. +- Reduces reliance on precise process model. + +**Fan integration:** +- Use dual-channel ADRC (heater + fan) or heater ADRC with fan as scheduled auxiliary. +- Apply fan envelope at command stage. + +**Complexity:** medium. + +### 5) Fuzzy supervisory control (over PID/PI inner loops) + +**What it is:** rule-based supervisor adjusts setpoints/gains/actuator splits based on roast context. + +**Why it fits:** +- Encodes operator heuristics explicitly. +- Useful when precise modeling is hard but domain expertise is strong. + +**Fan integration:** +- Rules can increase fan during high RoR overshoot risk and cap by user envelope. + +**Complexity:** medium; interpretability high. + +### 6) IMC / Smith-predictor MIMO variant + +**What it is:** model-based control compensating dead time, extending your current predictor concept into dual-actuator control. + +**Why it fits:** +- Builds directly on existing delay-estimation mechanics. +- Good compromise before full MPC. + +**Fan integration:** +- Use decoupling matrix from identified plant gains. +- Clamp fan by user bounds. + +**Complexity:** medium. + +## Recommended roadmap + +1. **Phase 1 (quick win):** implement fan envelope + **ADRC** as the primary advanced controller, while keeping current PID as fallback. +2. **Phase 2:** add gain scheduling and roast-phase-specific ADRC observer/controller parameters (drying/Maillard/development). +3. **Phase 3:** add optional LQI and MPC modes for sites that can maintain a reliable plant model. + +## Minimal API/firmware changes to support alternatives + +- Add `controlMode` enum in preferences/websocket payload, e.g.: + - `pid_single` (current) + - `pid_dual` + - `lqi` + - `mpc` + - `adrc` + - `fuzzy` +- Add `controlFanMin` / `controlFanMax` to preferences + websocket schema. +- Extend control telemetry to publish: + - actuator raw commands (`heaterRaw`, `fanRaw`) + - clamped commands (`heaterCmd`, `fanCmd`) + - active constraints flags (e.g., `fanAtMin`, `fanAtMax`). + +## Practical recommendation + +Given bean variability (origin, age, moisture, density, and batch mass), **ADRC is the best primary fit** because it tolerates modeling uncertainty and rejects disturbances without needing a highly accurate plant model. Start with **ADRC + fan min/max envelope**, retain PID as a safe fallback mode, and only enable LQI/MPC as optional modes for environments with stronger model identification and maintenance practices. + +### Why ADRC is a strong default for this roaster + +- Bean-dependent dynamics are hard to model precisely and can shift roast-to-roast. +- ADRC estimates lumped disturbances online, reducing dependence on exact model fidelity. +- It can co-manage heater/fan with fewer assumptions than model-heavy approaches. +- It maps well to incremental rollout: observer first, then tighter actuator coordination. + +## ADRC autotune proposal (how to make it practical) + +If we implement ADRC, autotune should shift from "PID gain hunt" to "plant + observer characterization". The objective is to estimate safe starting values for: + +- `b0_heater` (heater-to-temperature gain estimate) +- `b0_fan` (fan-to-cooling gain estimate) +- observer bandwidth `w0` +- controller bandwidth `wc` + +### Autotune modes + +1. **Quick tune (recommended default)** + - Single button workflow for operators. + - Uses a short sequence of bounded actuator steps and computes robust starting parameters. +2. **Advanced tune** + - Exposes full sweep settings (step amplitude, dwell time, fan baseline, repeat count). + - For power users validating different drum sizes/roaster configs. + +### Step-by-step ADRC autotune flow + +1. **Pre-check and safety lock** + - Require roast state = idle or dedicated calibration mode. + - Validate sensor health and stable sampling. + - Apply fan envelope constraints (`controlFanMin`, `controlFanMax`) before any step test. + +2. **Baseline stabilization phase (e.g., 20-40 s)** + - Hold fan at a user-selected baseline inside min/max envelope. + - Hold heater at a low safe value (or 0 for cooling characterization). + - Estimate noise level and moving slope baseline. + +3. **Heater gain test (`b0_heater`)** + - Apply a bounded heater step (example: +15%) for a fixed dwell. + - Measure slope change `dT/dt` after dead-time compensation window. + - Estimate `b0_heater` from incremental response: + - `b0_heater ~= Δ(dT/dt) / Δheater` + +4. **Fan cooling test (`b0_fan`)** + - With moderate heater hold, apply a bounded fan step (example: +10%). + - Measure slope reduction and estimate: + - `b0_fan ~= -Δ(dT/dt) / Δfan` + - Clamp all fan commands by user envelope during test and runtime. + +5. **Observer/controller bandwidth synthesis** + - Choose `w0` based on measured noise and response speed (faster plant -> higher `w0`). + - Set `wc` as a fraction of `w0` (typical start: `wc = w0 / 3` to `w0 / 5`). + - Produce conservative defaults first; allow optional "aggressive" profile. + +6. **Closed-loop verification pulse** + - Run a short setpoint move (e.g., +3 to +5 °C). + - Validate overshoot, settling time, and actuator saturation ratio. + - If metrics exceed thresholds, auto-derate (`wc` down, `w0` down) and retest once. + +7. **Persist + rollback safety** + - Save tuned ADRC params with timestamp and roast context metadata. + - Keep last-known-good profile; auto-rollback if the next roast triggers repeated saturation/oscillation alarms. + +### UI/API additions for ADRC autotune + +- Extend `setPidControl` into generic `setControl` (backward compatible alias retained). +- Add fields: + - `controlMode: "pid_single" | "adrc" | ...` + - `adrcAutotune: boolean` + - `adrcTuneLevel: "quick" | "advanced"` + - `adrcFanBaseline`, `adrcHeaterStep`, `adrcFanStep`, `adrcDwellSec` +- Telemetry: + - `adrcTuneStage`, `adrcTuneProgress`, `adrcB0Heater`, `adrcB0Fan`, `adrcW0`, `adrcWc` + - `adrcValidationOvershoot`, `adrcValidationSettlingSec`, `adrcValidationSaturationPct` + +### Why this autotune approach works for variable beans + +- It does not assume a fixed global bean model. +- It re-identifies local gains from fresh step data each tune. +- It keeps safety constraints explicit (heater bounds + user fan min/max). +- It is resilient to changing origin/age/batch by recalibrating dynamics instead of forcing one static parameter set. diff --git a/miniweb/index.html b/miniweb/index.html index 461c6ba..01e2808 100644 --- a/miniweb/index.html +++ b/miniweb/index.html @@ -7,6 +7,6 @@
- + diff --git a/miniweb/package-lock.json b/miniweb/package-lock.json new file mode 100644 index 0000000..82ee299 --- /dev/null +++ b/miniweb/package-lock.json @@ -0,0 +1,2109 @@ +{ + "name": "miniweb", + "version": "3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "miniweb", + "version": "3.0", + "dependencies": { + "preact": "^10.27.2" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.2", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", + "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@preact/preset-vite": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz", + "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==", + "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/@prefresh/babel-plugin": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", + "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prefresh/core": { + "version": "1.5.9", + "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz", + "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==", + "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/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/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "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/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "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/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "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/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/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-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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/electron-to-chromium": { + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/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/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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "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/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/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" + }, + "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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "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/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", + "dependencies": { + "kolorist": "^1.6.0" + } + }, + "node_modules/source-map": { + "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": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-trace": { + "version": "1.0.0-pre2", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", + "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-prerender-plugin": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz", + "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==", + "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/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/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/miniweb/package.json b/miniweb/package.json index b43a3f2..55d0ecf 100644 --- a/miniweb/package.json +++ b/miniweb/package.json @@ -1,7 +1,7 @@ { "name": "miniweb", "private": true, - "version": "0.0.0", + "version": "3.0", "type": "module", "scripts": { "dev": "vite", @@ -9,16 +9,11 @@ "preview": "vite preview" }, "devDependencies": { - "@types/chartjs-plugin-trendline": "^1.0.4", + "@preact/preset-vite": "^2.10.2", "typescript": "~5.6.2", "vite": "^6.0.3" }, "dependencies": { - "chart.js": "^4.4.7", - "chartjs-adapter-date-fns": "^3.0.0", - "date-fns": "^4.1.0", - "vanjs-core": "^1.5.2", - "vanjs-ext": "^0.6.2", - "vite-plugin-pwa": "^0.21.1" + "preact": "^10.27.2" } } diff --git a/miniweb/public/vite.svg b/miniweb/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/miniweb/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/miniweb/src/auth.ts b/miniweb/src/auth.ts new file mode 100644 index 0000000..5c89ed8 --- /dev/null +++ b/miniweb/src/auth.ts @@ -0,0 +1,20 @@ +const AUTH_STORAGE_KEY = "yaegerAdminSecret"; + +export function getAdminSecret(): string { + const existing = localStorage.getItem(AUTH_STORAGE_KEY); + if (existing && existing.length >= 8) { + return existing; + } + + const value = window.prompt("Enter Yaeger admin password", "") || ""; + if (value.length >= 8) { + localStorage.setItem(AUTH_STORAGE_KEY, value); + } + + return value; +} + +export function getBasicAuthHeaderValue(): string { + const secret = getAdminSecret(); + return `Basic ${btoa(`admin:${secret}`)}`; +} diff --git a/miniweb/src/autotune.tsx b/miniweb/src/autotune.tsx new file mode 100644 index 0000000..58c0a64 --- /dev/null +++ b/miniweb/src/autotune.tsx @@ -0,0 +1,505 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { AutotuneGraph } from "./graphs"; +import { getAdminSecret } from "./auth"; +import { sendWsCommand, useSocketState } from "./websocket"; + +type PidTarget = "BT" | "ET" | "simBT"; +type PidMethod = "ziegler-nichols" | "tyreus-luyben" | "pessen-integral" | "no-overshoot"; +type ControlMode = "pid" | "adrc"; + +export function AutotuneApp() { + const { lastMessage } = useSocketState(); + const [target, setTarget] = useState("BT"); + const [method, setMethod] = useState("ziegler-nichols"); + const [controlMode, setControlMode] = useState("pid"); + const [autotuneMode, setAutotuneMode] = useState("pid"); + const [setpoint, setSetpoint] = useState(20); + const [fanSpeed, setFanSpeed] = useState(50); + const [minHeaterPwm, setMinHeaterPwm] = useState(0); + const [maxHeaterPwm, setMaxHeaterPwm] = useState(60); + const [controlFanMin, setControlFanMin] = useState(30); + const [controlFanMax, setControlFanMax] = useState(80); + const [delayFan, setDelayFan] = useState(50); + const [delayHeater, setDelayHeater] = useState(60); + const [processDelaySec, setProcessDelaySec] = useState(0); + const [kp, setKp] = useState(1.0); + const [ki, setKi] = useState(0.1); + const [kd, setKd] = useState(0.01); + const [adrcB0, setAdrcB0] = useState(0.02); + const [adrcW0, setAdrcW0] = useState(1.0); + const [adrcWc, setAdrcWc] = useState(0.25); + const [history, setHistory] = useState>([]); + const [autotuneLog, setAutotuneLog] = useState([]); + const lastCrossing = useRef(-1); + const lastAdrcPhase = useRef(""); + const wasPidAutotuneRunning = useRef(false); + const wasAdrcAutotuneRunning = useRef(false); + const autotuneStopRequested = useRef(false); + const autotuneBoundsDirty = useRef(false); + const controlFanBoundsDirty = useRef(false); + const delayInputsDirty = useRef(false); + const adrcValuesDirty = useRef(false); + const controlModeDirty = useRef(false); + const autotuneModeDirty = useRef(false); + + const sendCommand = (data: Record) => { + const authToken = getAdminSecret(); + sendWsCommand({ ...data, authToken }); + }; + + useEffect(() => { + if (!lastMessage) return; + const pidAutotuneRunning = Boolean(lastMessage.pidAutotune); + const adrcAutotuneRunning = Boolean(lastMessage.adrcAutotune); + const pidAutotuneJustCompleted = wasPidAutotuneRunning.current && !pidAutotuneRunning; + const adrcAutotuneJustCompleted = wasAdrcAutotuneRunning.current && !adrcAutotuneRunning; + + if (lastMessage.controlMode === "pid" || lastMessage.controlMode === "adrc") { + if (!controlModeDirty.current) { + setControlMode(lastMessage.controlMode); + } else if (lastMessage.controlMode === controlMode) { + controlModeDirty.current = false; + } + } + if (lastMessage.autotuneMode === "pid" || lastMessage.autotuneMode === "adrc") { + if (!autotuneModeDirty.current) { + setAutotuneMode(lastMessage.autotuneMode); + } else if (lastMessage.autotuneMode === autotuneMode) { + autotuneModeDirty.current = false; + } + } + + if ( + lastMessage.pidAutotune && + typeof lastMessage.pidAutotuneCrossings === "number" && + lastMessage.pidAutotuneCrossings > 0 && + lastMessage.pidAutotuneCrossings !== lastCrossing.current + ) { + lastCrossing.current = lastMessage.pidAutotuneCrossings; + setAutotuneLog((prev) => [ + ...prev.slice(-24), + `Crossing ${lastMessage.pidAutotuneCrossings}/${lastMessage.pidAutotuneTargetCrossings ?? "?"} • Heater ${lastMessage.pidAutotuneHeaterCommand ?? "?"}%`, + ]); + } + + if (lastMessage.adrcAutotune && lastMessage.adrcAutotunePhase && lastMessage.adrcAutotunePhase !== lastAdrcPhase.current) { + lastAdrcPhase.current = lastMessage.adrcAutotunePhase; + setAutotuneLog((prev) => [ + ...prev.slice(-24), + `ADRC ${lastMessage.adrcAutotunePhase} • slope ${formatValue(lastMessage.adrcAutotunePeakSlope, 4)} °C/s`, + ]); + } else if (!lastMessage.adrcAutotune && lastAdrcPhase.current) { + lastAdrcPhase.current = ""; + } + + if (!lastMessage.pidAutotune && typeof lastMessage.pidKpActive === "number") { + const nextKp = lastMessage.pidKpActive; + const nextKi = lastMessage.pidKiActive ?? ki; + const nextKd = lastMessage.pidKdActive ?? kd; + setKp(nextKp); + setKi(nextKi); + setKd(nextKd); + if (pidAutotuneJustCompleted) { + const message = autotuneStopRequested.current + ? "PID autotune stopped." + : `PID tuning finished: Kp ${nextKp.toFixed(4)}, Ki ${nextKi.toFixed(4)}, Kd ${nextKd.toFixed(4)}`; + setAutotuneLog((prev) => [...prev.slice(-24), message]); + autotuneStopRequested.current = false; + } + } + + const nextAdrcB0 = lastMessage.adrcB0; + const nextAdrcW0 = lastMessage.adrcW0; + const nextAdrcWc = lastMessage.adrcWc; + if (typeof nextAdrcB0 === "number" && typeof nextAdrcW0 === "number" && typeof nextAdrcWc === "number") { + if (!adrcValuesDirty.current || adrcAutotuneJustCompleted) { + setAdrcB0(nextAdrcB0); + setAdrcW0(nextAdrcW0); + setAdrcWc(nextAdrcWc); + adrcValuesDirty.current = false; + if (adrcAutotuneJustCompleted) { + const message = autotuneStopRequested.current + ? "ADRC autotune stopped." + : `ADRC tuning finished: b0 ${nextAdrcB0.toFixed(4)}, w0 ${nextAdrcW0.toFixed(4)}, wc ${nextAdrcWc.toFixed(4)}`; + setAutotuneLog((prev) => [ + ...prev.slice(-24), + message, + ]); + autotuneStopRequested.current = false; + } + } else { + const b0Matches = Math.abs(nextAdrcB0 - adrcB0) < 0.0001; + const w0Matches = Math.abs(nextAdrcW0 - adrcW0) < 0.0001; + const wcMatches = Math.abs(nextAdrcWc - adrcWc) < 0.0001; + if (b0Matches && w0Matches && wcMatches) { + adrcValuesDirty.current = false; + } + } + } + + if (typeof lastMessage.pidAutotuneMin === "number" && typeof lastMessage.pidAutotuneMax === "number") { + if (!autotuneBoundsDirty.current) { + setMinHeaterPwm(lastMessage.pidAutotuneMin); + setMaxHeaterPwm(lastMessage.pidAutotuneMax); + } else { + const minMatches = Math.abs(lastMessage.pidAutotuneMin - minHeaterPwm) < 0.01; + const maxMatches = Math.abs(lastMessage.pidAutotuneMax - maxHeaterPwm) < 0.01; + if (minMatches && maxMatches) { + autotuneBoundsDirty.current = false; + } + } + } + if (typeof lastMessage.controlFanMin === "number" && typeof lastMessage.controlFanMax === "number") { + if (!controlFanBoundsDirty.current) { + setControlFanMin(lastMessage.controlFanMin); + setControlFanMax(lastMessage.controlFanMax); + } else { + const minMatches = Math.abs(lastMessage.controlFanMin - controlFanMin) < 0.01; + const maxMatches = Math.abs(lastMessage.controlFanMax - controlFanMax) < 0.01; + if (minMatches && maxMatches) { + controlFanBoundsDirty.current = false; + } + } + } + if (typeof lastMessage.pidDelayFan === "number" && typeof lastMessage.pidDelayHeater === "number") { + if (!delayInputsDirty.current) { + setDelayFan(lastMessage.pidDelayFan); + setDelayHeater(lastMessage.pidDelayHeater); + } else { + const delayFanMatches = Math.abs(lastMessage.pidDelayFan - delayFan) < 0.01; + const delayHeaterMatches = Math.abs(lastMessage.pidDelayHeater - delayHeater) < 0.01; + if (delayFanMatches && delayHeaterMatches) { + delayInputsDirty.current = false; + } + } + } + if (typeof lastMessage.pidProcessDelaySec === "number") setProcessDelaySec(lastMessage.pidProcessDelaySec); + + if (typeof lastMessage.ET === "number" && typeof lastMessage.BT === "number" && typeof lastMessage.simBT === "number") { + setHistory((prev) => [...prev, { ET: lastMessage.ET, BT: lastMessage.BT, simBT: Number(lastMessage.simBT) }].slice(-300)); + } + wasPidAutotuneRunning.current = pidAutotuneRunning; + wasAdrcAutotuneRunning.current = adrcAutotuneRunning; + }, [ + adrcB0, + adrcW0, + adrcWc, + autotuneMode, + controlFanMax, + controlFanMin, + controlMode, + delayFan, + delayHeater, + kd, + ki, + lastMessage, + maxHeaterPwm, + minHeaterPwm, + ]); + + const delayElapsedSec = + typeof lastMessage?.pidDelayMeasureElapsedSec === "number" ? lastMessage.pidDelayMeasureElapsedSec.toFixed(1) : "0.0"; + const measuredDelaySec = + typeof lastMessage?.pidMeasuredProcessDelaySec === "number" ? lastMessage.pidMeasuredProcessDelaySec : processDelaySec; + const isAutotuneRunning = Boolean(lastMessage?.pidAutotune || lastMessage?.adrcAutotune); + const autotuneProgress = + autotuneMode === "adrc" + ? `ADRC ${lastMessage?.adrcAutotunePhase ?? "idle"} • ${formatValue(lastMessage?.adrcAutotuneElapsedSec, 1)}s` + : `Crossings ${lastMessage?.pidAutotuneCrossings ?? 0}/${lastMessage?.pidAutotuneTargetCrossings ?? "?"}`; + + return ( +
+

Controller Autotune

+
+ Mode {autotuneMode.toUpperCase()} • Autotune: {isAutotuneRunning ? "Running" : "Idle"} • {autotuneProgress} +
+ +
+
+

PID memo

+

+ Relay autotune toggles the heater between Min PWM and Max PWM around the setpoint. After repeated crossings it estimates + Ku and Pu, then writes Kp, Ki, and Kd using the selected method. +

+

The result appears in Kp, Ki, and Kd below and is saved for the selected target sensor.

+
+
+

ADRC memo

+

+ Step autotune holds heat off for 10 seconds, applies a 60% heater step for 25 seconds, then estimates b0 from the fastest + positive temperature slope. +

+

The fan uses the automatic min/max range during tuning. The result appears in b0, w0, and wc below.

+
+
+
+

Autotune values

+
+ Kp {formatValue(lastMessage?.pidKpActive ?? kp, 4)} + Ki {formatValue(lastMessage?.pidKiActive ?? ki, 4)} + Kd {formatValue(lastMessage?.pidKdActive ?? kd, 4)} + Ku {formatValue(lastMessage?.pidAutotuneKu, 4)} + Pu {formatValue(lastMessage?.pidAutotunePu, 2)}s + Peaks {formatValue(lastMessage?.pidAutotuneAvgPeakLow, 2)} / {formatValue(lastMessage?.pidAutotuneAvgPeakHigh, 2)} + b0 {formatValue(lastMessage?.adrcB0 ?? adrcB0, 4)} + w0 {formatValue(lastMessage?.adrcW0 ?? adrcW0, 4)} + wc {formatValue(lastMessage?.adrcWc ?? adrcWc, 4)} + ADRC slope {formatValue(lastMessage?.adrcAutotunePeakSlope, 4)} °C/s + ADRC baseline {formatValue(lastMessage?.adrcAutotuneBaselineTemp, 2)} °C + Step {formatValue(lastMessage?.adrcAutotuneHeaterStep, 0)}% +
+
+
+ + + + + + + + + + setSetpoint(Number((e.target as HTMLInputElement).value) || 0)} /> + + setFanSpeed(Number((e.target as HTMLInputElement).value) || 0)} /> + +
+ { + controlFanBoundsDirty.current = true; + setControlFanMin(Number((e.target as HTMLInputElement).value) || 0); + }} + /> + { + controlFanBoundsDirty.current = true; + setControlFanMax(Number((e.target as HTMLInputElement).value) || 0); + }} + /> +
+ + { + autotuneBoundsDirty.current = true; + setMinHeaterPwm(Number((e.target as HTMLInputElement).value) || 0); + }} + /> + + { + autotuneBoundsDirty.current = true; + setMaxHeaterPwm(Number((e.target as HTMLInputElement).value) || 0); + }} + /> + +
+ setKp(Number((e.target as HTMLInputElement).value) || 0)} /> + setKi(Number((e.target as HTMLInputElement).value) || 0)} /> + setKd(Number((e.target as HTMLInputElement).value) || 0)} /> +
+ +
+ { + adrcValuesDirty.current = true; + setAdrcB0(Number((e.target as HTMLInputElement).value) || 0); + }} + /> + { + adrcValuesDirty.current = true; + setAdrcW0(Number((e.target as HTMLInputElement).value) || 0); + }} + /> + { + adrcValuesDirty.current = true; + setAdrcWc(Number((e.target as HTMLInputElement).value) || 0); + }} + /> +
+ +
+ { + delayInputsDirty.current = true; + setDelayFan(Number((e.target as HTMLInputElement).value) || 0); + }} + /> + { + delayInputsDirty.current = true; + setDelayHeater(Number((e.target as HTMLInputElement).value) || 0); + }} + /> +
+ + setProcessDelaySec(Number((e.target as HTMLInputElement).value) || 0)} /> +
+
+ + + + + + + +
+
+ Delay measure: {lastMessage?.pidDelayMeasureState ?? "idle"} • elapsed {delayElapsedSec}s • measured {measuredDelaySec}s +
+
{autotuneLog.slice(-25).join("\n")}
+
+ ); +} + +function formatValue(value: number | null | undefined, digits = 2) { + return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "N/A"; +} + +function normalizeFanBounds(min: number, max: number) { + const safeMin = clampPercent(min); + const safeMax = clampPercent(max); + return { + min: Math.min(safeMin, safeMax), + max: Math.max(safeMin, safeMax), + }; +} + +function clampPercent(value: number) { + return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0; +} diff --git a/miniweb/src/chart.ts b/miniweb/src/chart.ts deleted file mode 100644 index 254ef4b..0000000 --- a/miniweb/src/chart.ts +++ /dev/null @@ -1,252 +0,0 @@ -import chartTrendline from "chartjs-plugin-trendline"; -import "chartjs-adapter-date-fns"; -import { Chart, plugins } from "chart.js/auto"; -import { RoastState, YaegerMessage } from "./model.ts"; - -export function initializeChart(ctx: CanvasRenderingContext2D): Chart { - return new Chart(ctx, { - type: "line", - data: { - labels: [], - datasets: [ - { - label: "Bean Temp", - borderColor: "blue", - pointStyle: false, - data: [], - yAxisID: "y1", - tension: 0.4, - }, - { - label: "Exhaust Temp", - borderColor: "red", - pointStyle: false, - data: [], - yAxisID: "y1", - tension: 0.4, - }, - { - label: "Fan Power", - borderColor: "#055088", - pointStyle: false, - data: [], - yAxisID: "y2", - tension: 0.1, - }, - { - label: "Heater Power", - pointStyle: false, - borderColor: "orange", - data: [], - yAxisID: "y2", - tension: 0.1, - }, - ], - }, - options: { - interaction: { - intersect: false, - mode: "index", - axis: "xy", - }, - plugins: { - tooltip: { - callbacks: { - title: function (item) { - const x = item[0].parsed.x; - if (x < 60) { - return `${x} seconds`; - } - return `${Math.floor(x / 60)} minutes, ${(x % 60).toFixed(2)} seconds`; - }, - }, - }, - }, - scales: { - x: { - grace: 5, - type: "linear", - bounds: "ticks", - beginAtZero: true, - title: { - display: true, - text: "Time", - }, - ticks: { - stepSize: 60, - callback: function (value: any, __, _) { - if (value <= 60) { - return `${value}s`; - } else { - const minutes = Math.floor(value / 60); - return `${minutes}m`; - } - }, - }, - }, - //x: { type: 'time', time: { unit: 'minute' } }, - y1: { - min: 0, - max: 300, - type: "linear", - position: "left", - title: { - display: true, - text: "Temperature (°C)", - }, - }, - y2: { - min: 0, - max: 100, - type: "linear", - position: "right", - title: { - display: true, - text: "Fan/Heater power (%)", - }, - }, - y3: { - min: 0, - max: 60, - //type: "logarithmic", - }, - }, - responsive: true, - animation: false, - }, - lineAtIndex: [], - plugins: [verticalLinePlugin], - }); -} - -export function updateChart(chart: Chart, roast: RoastState) { - chart.data.datasets[0].data = roast.measurements.map((el) => el.message.BT); - chart.data.datasets[1].data = roast.measurements.map((el) => el.message.ET); - chart.data.datasets[2].data = roast.measurements.map( - (el) => el.message.FanVal, - ); - - const { measurements, startDate } = roast; - const timestamps = measurements.map( - (el) => (el.timestamp.getTime() - startDate.getTime()) / 1000, - ); - const beanTemps = measurements.map((el) => el.message.BT); - const envTemps = measurements.map((el) => el.message.ET); - - const windowSize = 30; - - // Helper to calculate rate of rise (RoR) - const calculateRoR = (temps: number[], times: number[]) => - temps.map((temp, i) => { - if (i === 0) return null; // No RoR for the first data point - const deltaTemp = temp - temps[i - 1]; - const deltaTime = times[i] - times[i - 1]; - return deltaTime > 0 ? deltaTemp / deltaTime : 0; - }); - - // Helper to calculate rolling average - const applyRollingAverage = (values: (number | null)[], size: number) => { - return values.map((val, i, arr) => { - if (val === null || i < size - 1) return val; // Skip if insufficient data - const window = arr.slice(i - size + 1, i + 1) as number[]; - return window.reduce((sum, v) => sum + v * 60, 0) / size; - }); - }; - - // Calculate RoR and apply rolling averages - const btRor = applyRollingAverage( - calculateRoR(beanTemps, timestamps), - windowSize, - ); - const etRor = applyRollingAverage( - calculateRoR(envTemps, timestamps), - windowSize, - ); - - // Add datasets to chart - chart.data.datasets[4] = { - label: "BT Rate of Rise (°C/min)", - borderColor: "green", - pointStyle: false, - data: btRor, - yAxisID: "y3", - tension: 0.2, - }; - - chart.data.datasets[5] = { - label: "ET Rate of Rise (°C/min)", - borderColor: "purple", - pointStyle: false, - data: etRor, - yAxisID: "y3", - tension: 0.2, - }; - - chart.data.datasets[6] = { - label: "Setpoint (°C)", - borderColor: "#03fc7b", - pointStyle: false, - data: roast.measurements.map((el) => el.extra?.setpoint ?? 0), - yAxisID: "y1", - tension: 0.1, - - }; - - chart.data.datasets[3].data = roast.measurements.map( - (el) => el.message.BurnerVal, - ); - chart.data.labels = roast.measurements.map( - (el) => `${(el.timestamp.getTime() - roast.startDate.getTime()) / 1000}`, - ); - chart.config._config.lineAtIndex = roast.events.map((event) => { - return { - label: event.label, - idx: roast.measurements.findIndex((el) => { - return ( - Math.abs( - el.timestamp.getTime() - event.measurement.timestamp.getTime(), - ) < 500 - ); - }), - }; - }); - chart.update(); -} - -const verticalLinePlugin = { - getLinePosition: function (chart, pointIndex) { - const meta = chart.getDatasetMeta(0); // first dataset is used to discover X coordinate of a point - const data = meta.data; - return data[pointIndex.idx].x; - }, - - renderVerticalLine: function (chartInstance, pointIndex) { - const lineLeftOffset = this.getLinePosition(chartInstance, pointIndex); - const scale = chartInstance.scales.y1; - const context = chartInstance.ctx; - // render vertical line - context.beginPath(); - context.strokeStyle = "#ff0000"; - context.moveTo(lineLeftOffset, scale.top); - context.lineTo(lineLeftOffset, scale.bottom); - context.stroke(); - - // write label - context.fillStyle = "#ff0000"; - context.textAlign = "trailing"; - context.fillText( - pointIndex.label, - lineLeftOffset, - scale.bottom - 20, - // (scale.bottom - scale.top) / 2 + scale.top, - ); - }, - - beforeDatasetsDraw: function (chart, easing) { - if (chart.config._config.lineAtIndex) { - chart.config._config.lineAtIndex.forEach((pointIndex) => { - this.renderVerticalLine(chart, pointIndex); - }); - } - }, -}; diff --git a/miniweb/src/coffeeBeans.tsx b/miniweb/src/coffeeBeans.tsx new file mode 100644 index 0000000..4d5403e --- /dev/null +++ b/miniweb/src/coffeeBeans.tsx @@ -0,0 +1,406 @@ +import { useEffect, useRef } from "preact/hooks"; + +type BeanParticle = { + x: number; + y: number; + vx: number; + vy: number; + size: number; + angle: number; + spin: number; + opacity: number; + life: number; + maxLife: number; + driftSeed: number; +}; + +type AvoidRect = { + left: number; + right: number; + top: number; + bottom: number; +}; + +const MAX_DPR = 1.5; +const DESKTOP_BEANS = 72; +const MOBILE_BEANS = 40; +const MOBILE_BREAKPOINT = 880; +const OBSTACLE_MARGIN = 26; +const OBSTACLE_INFLUENCE_RADIUS = 110; +const POINTER_RADIUS = 230; +const BEAN_REPULSION_RADIUS = 42; +const BEAN_REPULSION_STRENGTH = 0.0008; +const LAYOUT_REFILL_RATIO = 0.32; +const BOTTOM_SPAWN_BAND = 90; +const OFFSCREEN_PADDING = 54; + +function normalizedRandom(seed: number) { + const x = Math.sin(seed * 12.9898) * 43758.5453; + return x - Math.floor(x); +} + +function randomRange(min: number, max: number) { + return min + Math.random() * (max - min); +} + +function smoothstep(edge0: number, edge1: number, value: number) { + const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +function mixChannel(a: number, b: number, t: number) { + return Math.round(a + (b - a) * t); +} + +function isInsideAvoidRect(x: number, y: number, avoidRects: AvoidRect[]) { + return avoidRects.some((rect) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom); +} + +function randomOpenPosition(width: number, height: number, avoidRects: AvoidRect[]) { + for (let i = 0; i < 24; i += 1) { + const x = Math.random() * width; + const y = Math.random() * height; + if (!isInsideAvoidRect(x, y, avoidRects)) return { x, y }; + } + return { x: Math.random() * width, y: Math.random() * height }; +} + +function spawnPosition(width: number, height: number, avoidRects: AvoidRect[], fromBottom: boolean) { + if (!fromBottom) return randomOpenPosition(width, height, avoidRects); + + for (let i = 0; i < 24; i += 1) { + const x = Math.random() * width; + const y = height + randomRange(8, BOTTOM_SPAWN_BAND); + if (!isInsideAvoidRect(x, Math.min(y, height - 1), avoidRects)) return { x, y }; + } + + return { x: Math.random() * width, y: height + randomRange(8, BOTTOM_SPAWN_BAND) }; +} + +function createBean(width: number, height: number, avoidRects: AvoidRect[], index: number, fromBottom = false): BeanParticle { + const s = index + 1 + Math.random() * 17; + const pos = spawnPosition(width, height, avoidRects, fromBottom); + const size = 8 + normalizedRandom(s * 3.33) * 14; + const maxLife = 950 + normalizedRandom(s * 9.2) * 1050; + + return { + x: pos.x, + y: pos.y, + vx: randomRange(-0.08, 0.08), + vy: randomRange(-0.08, -0.025), + size, + angle: normalizedRandom(s * 4.4) * Math.PI * 2, + spin: (normalizedRandom(s * 5.8) - 0.5) * 0.004, + opacity: 0.08 + normalizedRandom(s * 6.6) * 0.17, + life: fromBottom ? 0 : randomRange(maxLife * 0.08, maxLife * 0.7), + maxLife, + driftSeed: normalizedRandom(s * 10.7) * Math.PI * 2, + }; +} + +function createBeans(count: number, width: number, height: number, avoidRects: AvoidRect[]): BeanParticle[] { + return Array.from({ length: count }, (_, index) => createBean(width, height, avoidRects, index)); +} + +function refillBeans(beans: BeanParticle[], width: number, height: number, avoidRects: AvoidRect[]) { + const moved = Math.max(1, Math.floor(beans.length * LAYOUT_REFILL_RATIO)); + for (let i = 0; i < moved; i += 1) { + const index = (i * 7) % beans.length; + beans[index] = createBean(width, height, avoidRects, index); + } +} + +function getAvoidRects(): AvoidRect[] { + const nodes = document.querySelectorAll(".tabs-nav, .tab-content"); + return Array.from(nodes) + .map((node) => { + const rect = node.getBoundingClientRect(); + return { + left: rect.left - OBSTACLE_MARGIN, + right: rect.right + OBSTACLE_MARGIN, + top: rect.top - OBSTACLE_MARGIN, + bottom: rect.bottom + OBSTACLE_MARGIN, + }; + }) + .filter((rect) => rect.right > 0 && rect.left < window.innerWidth && rect.bottom > 0 && rect.top < window.innerHeight); +} + +function colorBetween(a: [number, number, number], b: [number, number, number], t: number) { + return [mixChannel(a[0], b[0], t), mixChannel(a[1], b[1], t), mixChannel(a[2], b[2], t)] as const; +} + +function roastColor(roastProgress: number) { + const green: [number, number, number] = [126, 166, 79]; + const yellow: [number, number, number] = [219, 181, 70]; + const brown: [number, number, number] = [78, 39, 18]; + + if (roastProgress < 0.38) { + return colorBetween(green, yellow, roastProgress / 0.38); + } + + return colorBetween(yellow, brown, (roastProgress - 0.38) / 0.62); +} + +function beanOpacity(bean: BeanParticle) { + const progress = bean.life / bean.maxLife; + const fadeIn = smoothstep(0, 0.12, progress); + const fadeOut = 1 - smoothstep(0.72, 1, progress); + return bean.opacity * fadeIn * fadeOut; +} + +function beanFill(bean: BeanParticle) { + const progress = Math.max(0, Math.min(1, bean.life / bean.maxLife)); + const [r, g, b] = roastColor(progress); + return `rgba(${r}, ${g}, ${b}, ${beanOpacity(bean).toFixed(3)})`; +} + +function beanCrease(bean: BeanParticle) { + const progress = Math.max(0, Math.min(1, bean.life / bean.maxLife)); + const [r, g, b] = roastColor(progress); + return `rgba(${Math.round(r * 0.46)}, ${Math.round(g * 0.43)}, ${Math.round(b * 0.46)}, ${(beanOpacity(bean) * 1.35).toFixed(3)})`; +} + +function applyObstacleRepulsion(bean: BeanParticle, avoidRects: AvoidRect[], dt: number) { + for (const rect of avoidRects) { + const closestX = Math.max(rect.left, Math.min(bean.x, rect.right)); + const closestY = Math.max(rect.top, Math.min(bean.y, rect.bottom)); + const dx = bean.x - closestX; + const dy = bean.y - closestY; + const distanceSq = dx * dx + dy * dy; + + if (distanceSq > OBSTACLE_INFLUENCE_RADIUS * OBSTACLE_INFLUENCE_RADIUS) { + continue; + } + + if (distanceSq > 1) { + const distance = Math.sqrt(distanceSq); + const strength = (OBSTACLE_INFLUENCE_RADIUS - distance) / OBSTACLE_INFLUENCE_RADIUS; + bean.vx += (dx / distance) * strength * 0.02 * dt; + bean.vy += (dy / distance) * strength * 0.02 * dt; + continue; + } + + const toLeft = Math.abs(bean.x - rect.left); + const toRight = Math.abs(rect.right - bean.x); + const toTop = Math.abs(bean.y - rect.top); + const toBottom = Math.abs(rect.bottom - bean.y); + const minEdge = Math.min(toLeft, toRight, toTop, toBottom); + const escapeImpulse = 0.1 * dt; + + if (minEdge === toLeft) bean.vx -= escapeImpulse; + else if (minEdge === toRight) bean.vx += escapeImpulse; + else if (minEdge === toTop) bean.vy -= escapeImpulse; + else bean.vy += escapeImpulse; + } +} + +function limitSpeed(bean: BeanParticle) { + const maxSpeed = 1.2; + const speed = Math.hypot(bean.vx, bean.vy); + if (speed <= maxSpeed) return; + bean.vx = (bean.vx / speed) * maxSpeed; + bean.vy = (bean.vy / speed) * maxSpeed; +} + +export function CoffeeBeanBackground() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + let width = 0; + let height = 0; + let dpr = 1; + let rafId = 0; + let running = true; + let layoutSignature = ""; + let lastFrameAt = performance.now(); + let frameCount = 0; + + const pointer = { x: 0, y: 0, active: false }; + let beans: BeanParticle[] = []; + + const resize = () => { + width = window.innerWidth; + height = window.innerHeight; + dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const avoidRects = getAvoidRects(); + const beanCount = width <= MOBILE_BREAKPOINT ? MOBILE_BEANS : DESKTOP_BEANS; + beans = createBeans(beanCount, width, height, avoidRects); + layoutSignature = ""; + }; + + const drawBean = (bean: BeanParticle) => { + const opacity = beanOpacity(bean); + if (opacity <= 0.001) return; + + ctx.save(); + ctx.translate(bean.x, bean.y); + ctx.rotate(bean.angle); + + const w = bean.size; + const h = bean.size * 1.45; + + ctx.fillStyle = beanFill(bean); + ctx.beginPath(); + ctx.ellipse(0, 0, w * 0.55, h * 0.55, 0, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = beanCrease(bean); + ctx.lineWidth = Math.max(1, w * 0.08); + ctx.beginPath(); + ctx.moveTo(0, -h * 0.34); + ctx.quadraticCurveTo(-w * 0.2, 0, 0, h * 0.34); + ctx.stroke(); + + ctx.restore(); + }; + + const frame = (now = performance.now()) => { + if (!running) return; + + const dt = Math.min(2.5, Math.max(0.35, (now - lastFrameAt) / 16.67)); + lastFrameAt = now; + frameCount += 1; + + ctx.clearRect(0, 0, width, height); + const avoidRects = getAvoidRects(); + const nextSignature = avoidRects + .map((rect) => `${Math.round(rect.left)}:${Math.round(rect.top)}:${Math.round(rect.right)}:${Math.round(rect.bottom)}`) + .join("|"); + if (nextSignature !== layoutSignature) { + layoutSignature = nextSignature; + refillBeans(beans, width, height, avoidRects); + } + + if (!reducedMotion) { + for (let i = 0; i < beans.length; i += 1) { + for (let j = i + 1; j < beans.length; j += 1) { + const a = beans[i]; + const b = beans[j]; + const dx = a.x - b.x; + const dy = a.y - b.y; + const distanceSq = dx * dx + dy * dy; + if (distanceSq < 1 || distanceSq > BEAN_REPULSION_RADIUS * BEAN_REPULSION_RADIUS) continue; + const distance = Math.sqrt(distanceSq); + const force = ((BEAN_REPULSION_RADIUS - distance) / BEAN_REPULSION_RADIUS) * BEAN_REPULSION_STRENGTH * dt; + const ux = dx / distance; + const uy = dy / distance; + a.vx += ux * force; + a.vy += uy * force; + b.vx -= ux * force; + b.vy -= uy * force; + } + } + } + + for (let i = 0; i < beans.length; i += 1) { + const bean = beans[i]; + + if (!reducedMotion) { + if (pointer.active) { + const dx = bean.x - pointer.x; + const dy = bean.y - pointer.y; + const distanceSq = dx * dx + dy * dy; + if (distanceSq > 1 && distanceSq < POINTER_RADIUS * POINTER_RADIUS) { + const distance = Math.sqrt(distanceSq); + const force = (POINTER_RADIUS - distance) / POINTER_RADIUS; + const ux = dx / distance; + const uy = dy / distance; + bean.vx += ux * force * 0.018 * dt; + bean.vy += uy * force * 0.014 * dt; + } + } + + applyObstacleRepulsion(bean, avoidRects, dt); + + const progress = Math.max(0, Math.min(1, bean.life / bean.maxLife)); + const updraft = 0.0045 + (1 - progress) * 0.0016; + const sidewaysDraft = Math.sin(frameCount * 0.012 + bean.driftSeed) * 0.0024; + bean.vx += sidewaysDraft * dt; + bean.vy -= updraft * dt; + + bean.x += bean.vx * dt; + bean.y += bean.vy * dt; + bean.angle += bean.spin * dt; + bean.life += dt; + + bean.vx *= Math.pow(0.988, dt); + bean.vy *= Math.pow(0.988, dt); + limitSpeed(bean); + + if (bean.x < -OFFSCREEN_PADDING) bean.x = width + OFFSCREEN_PADDING; + else if (bean.x > width + OFFSCREEN_PADDING) bean.x = -OFFSCREEN_PADDING; + if (bean.y < -OFFSCREEN_PADDING || bean.life >= bean.maxLife) { + beans[i] = createBean(width, height, avoidRects, i, true); + continue; + } + } + + drawBean(bean); + } + + if (!reducedMotion) { + rafId = requestAnimationFrame(frame); + } + }; + + const onPointerMove = (event: PointerEvent) => { + pointer.x = event.clientX; + pointer.y = event.clientY; + pointer.active = true; + }; + + const onPointerLeave = () => { + pointer.active = false; + }; + + const onVisibility = () => { + running = document.visibilityState === "visible"; + if (running && !reducedMotion && !rafId) { + lastFrameAt = performance.now(); + rafId = requestAnimationFrame(frame); + } + if (!running && rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + }; + + resize(); + frame(); + + window.addEventListener("resize", resize); + window.addEventListener("pointermove", onPointerMove, { passive: true }); + window.addEventListener("pointerleave", onPointerLeave); + document.addEventListener("visibilitychange", onVisibility); + + return () => { + running = false; + if (rafId) cancelAnimationFrame(rafId); + window.removeEventListener("resize", resize); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerleave", onPointerLeave); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, []); + + return ( + + ); +} diff --git a/miniweb/src/graphs.tsx b/miniweb/src/graphs.tsx new file mode 100644 index 0000000..24b3420 --- /dev/null +++ b/miniweb/src/graphs.tsx @@ -0,0 +1,419 @@ +import { Profile, ProfileStep, RoastState } from "./model"; + +type EventMarker = { label: string; sec: number; color?: string }; + +type GraphSeries = { + label: string; + color: string; + samples?: number[]; + values: Array; +}; + +type LineGraphProps = { + title: string; + samples: number[]; + series: GraphSeries[]; + minY: number; + maxY: number; + height: number; + eventTimes?: EventMarker[]; +}; + +const WIDTH = 900; +const MARGIN = { top: 20, right: 20, bottom: 34, left: 52 }; + +function createScale(domainMin: number, domainMax: number, rangeMin: number, rangeMax: number) { + const domainSpan = domainMax - domainMin || 1; + const rangeSpan = rangeMax - rangeMin; + return (value: number) => rangeMin + ((value - domainMin) / domainSpan) * rangeSpan; +} + +function formatTick(value: number) { + if (Math.abs(value) >= 100 || Number.isInteger(value)) { + return value.toFixed(0); + } + return value.toFixed(1); +} + +function linePath( + samples: number[], + values: Array, + xScale: (value: number) => number, + yScale: (value: number) => number, +) { + let path = ""; + let drawing = false; + + for (let i = 0; i < values.length; i += 1) { + const value = values[i]; + const sample = samples[i]; + if (value == null || sample == null || !Number.isFinite(value) || !Number.isFinite(sample)) { + drawing = false; + continue; + } + + const x = xScale(sample); + const y = yScale(value); + path += `${drawing ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)} `; + drawing = true; + } + + return path.trim(); +} + +function buildRoR(values: number[], timeSeconds: number[], windowSize = 20): Array { + const rate = values.map((temp, i) => { + if (i === 0) return null; + const deltaT = temp - values[i - 1]; + const deltaS = timeSeconds[i] - timeSeconds[i - 1]; + const value = deltaS > 0 ? (deltaT / deltaS) * 60 : null; + return value != null && Number.isFinite(value) ? value : null; + }); + + return rate.map((value, i, arr) => { + if (value == null || i < windowSize - 1) return value; + const window = arr + .slice(i - windowSize + 1, i + 1) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (!window.length) return null; + return window.reduce((sum, v) => sum + v, 0) / window.length; + }); +} + +function gridTicks(minY: number, maxY: number, count = 5) { + const step = (maxY - minY) / count; + return Array.from({ length: count + 1 }, (_, i) => minY + i * step); +} + +function interpolateProfileValue( + start: number, + end: number, + progress: number, + type: ProfileStep["interpolation"], +): number { + switch (type) { + case "linear": + return start + (end - start) * progress; + case "ease-in": + return start + (end - start) * Math.pow(progress, 2); + case "ease-out": + return start + (end - start) * (1 - Math.pow(1 - progress, 2)); + case "ease-in-out": + return ( + start + + (end - start) * + (progress < 0.5 + ? 2 * Math.pow(progress, 2) + : 1 - Math.pow(-2 * progress + 2, 2) / 2) + ); + default: + return end; + } +} + +function clampProgress(value: number) { + return Math.min(1, Math.max(0, value)); +} + +function getProfileSetpointAtElapsed(profile: Profile, elapsedSeconds: number): number | null { + if (!profile.steps.length) return null; + let accumulated = 0; + + for (let i = 0; i < profile.steps.length; i += 1) { + const step = profile.steps[i]; + const stepStart = accumulated; + accumulated += step.duration; + if (elapsedSeconds <= accumulated) { + const progress = step.duration > 0 ? (elapsedSeconds - stepStart) / step.duration : 1; + const previousSetpoint = i === 0 ? step.setpoint : profile.steps[i - 1].setpoint; + return interpolateProfileValue(previousSetpoint, step.setpoint, clampProgress(progress), step.interpolation); + } + } + + return profile.steps[profile.steps.length - 1].setpoint; +} + +function getProfileFanAtElapsed(profile: Profile, elapsedSeconds: number): number | null { + if (!profile.steps.length) return null; + let accumulated = 0; + + for (const step of profile.steps) { + accumulated += Math.max(0, step.duration); + if (elapsedSeconds <= accumulated) { + return typeof step.fanValue === "number" ? step.fanValue : null; + } + } + + const lastStep = profile.steps[profile.steps.length - 1]; + return typeof lastStep.fanValue === "number" ? lastStep.fanValue : null; +} + +function getProfileTotalDuration(profile: Profile) { + return profile.steps.reduce((sum, step) => sum + Math.max(0, step.duration), 0); +} + +function buildProfileSamples(profile: Profile) { + const totalDuration = Math.max(1, Math.ceil(getProfileTotalDuration(profile))); + const sampleStep = Math.max(1, Math.ceil(totalDuration / 360)); + const samples = new Set([0, totalDuration]); + + for (let seconds = 0; seconds <= totalDuration; seconds += sampleStep) { + samples.add(seconds); + } + + let accumulated = 0; + for (const step of profile.steps) { + accumulated += Math.max(0, step.duration); + samples.add(Math.min(totalDuration, Math.max(0, Math.round(accumulated)))); + } + + return [...samples].sort((a, b) => a - b); +} + +function getProfileMarkers(profile: Profile): EventMarker[] { + let accumulated = 0; + return profile.steps.map((step, index) => { + const marker = { + label: step.name || step.tag || `Phase ${index + 1}`, + sec: accumulated, + color: "#facc15", + }; + accumulated += Math.max(0, step.duration); + return marker; + }); +} + +function hasDrawableSeries(samples: number[], series: GraphSeries[]) { + return series.some((s) => { + const seriesSamples = s.samples ?? samples; + let pointCount = 0; + for (let i = 0; i < s.values.length; i += 1) { + const value = s.values[i]; + const sample = seriesSamples[i]; + if (value != null && sample != null && Number.isFinite(value) && Number.isFinite(sample)) { + pointCount += 1; + if (pointCount >= 2) return true; + } + } + return false; + }); +} + +function getMaxSample(samples: number[], series: GraphSeries[], eventTimes: EventMarker[]) { + let maxSample = 1; + const consider = (value: number) => { + if (Number.isFinite(value)) maxSample = Math.max(maxSample, value); + }; + + samples.forEach(consider); + eventTimes.forEach((event) => consider(event.sec)); + series.forEach((s) => (s.samples ?? samples).forEach(consider)); + return maxSample; +} + +function LineGraph({ title, samples, series, minY, maxY, height, eventTimes = [] }: LineGraphProps) { + if (!hasDrawableSeries(samples, series)) { + return
{title}: waiting for samples…
; + } + + const innerWidth = WIDTH - MARGIN.left - MARGIN.right; + const innerHeight = height - MARGIN.top - MARGIN.bottom; + const maxSample = getMaxSample(samples, series, eventTimes); + const xScale = createScale(0, maxSample, 0, innerWidth); + const yScale = createScale(minY, maxY, innerHeight, 0); + const yTicks = gridTicks(minY, maxY, 5); + const xTicks = gridTicks(0, maxSample, 5); + + return ( +
+

{title}

+ + + + {yTicks.map((tick) => ( + + ))} + + {xTicks.map((tick) => ( + + ))} + + {eventTimes.map((event) => { + const x = xScale(event.sec); + const color = event.color ?? "#ef4444"; + return ( + + + {event.label} + + ); + })} + + {series.map((s) => { + const path = linePath(s.samples ?? samples, s.values, xScale, yScale); + return path ? ( + + ) : null; + })} + + + + + {xTicks.map((tick) => ( + + + + {formatTick(tick)} + + + ))} + + {yTicks.map((tick) => ( + + + + {formatTick(tick)} + + + ))} + + +
+ {series.map((s) => ( + + {s.label} + + ))} +
+
+ ); +} + +export function RoastGraphs({ + roast, + heightScale = 1, + profile, +}: { + roast?: RoastState; + heightScale?: number; + profile?: Profile; +}) { + const measurements = roast?.measurements ?? []; + const start = roast?.startDate; + const activeProfile = roast?.profile ?? profile; + const profileSamples = activeProfile?.steps.length ? buildProfileSamples(activeProfile) : []; + + if (!measurements.length && !profileSamples.length) { + return
Load a profile or start logging to see the roast graph.
; + } + + const sampleTimes = start ? measurements.map((m) => (m.timestamp.getTime() - start.getTime()) / 1000) : []; + const bt = measurements.map((m) => m.message.BT); + const et = measurements.map((m) => m.message.ET); + const setpoint = measurements.map((m) => m.extra?.setpoint ?? 0); + const fan = measurements.map((m) => m.message.FanVal); + const heater = measurements.map((m) => m.message.BurnerVal); + const btRor = buildRoR(bt, sampleTimes); + const etRor = buildRoR(et, sampleTimes); + const profileSetpoint = activeProfile + ? profileSamples.map((seconds) => getProfileSetpointAtElapsed(activeProfile, seconds)) + : []; + const profileFan = activeProfile + ? profileSamples.map((seconds) => { + const value = getProfileFanAtElapsed(activeProfile, seconds); + return value == null ? null : value * 3; + }) + : []; + + const eventTimes = start + ? (roast?.events ?? []).map((event) => ({ + label: String(event.label), + sec: (event.measurement.timestamp.getTime() - start.getTime()) / 1000, + })) + : []; + const profileMarkers = activeProfile ? getProfileMarkers(activeProfile) : []; + + const clampedHeightScale = Math.min(1.8, Math.max(0.7, heightScale)); + const combinedHeight = Math.round(380 * clampedHeightScale); + const series: GraphSeries[] = []; + + if (profileSetpoint.length) { + series.push({ label: "Profile setpoint", color: "#facc15", samples: profileSamples, values: profileSetpoint }); + } + if (profileFan.some((value) => value != null)) { + series.push({ label: "Profile fan % (x3)", color: "#67e8f9", samples: profileSamples, values: profileFan }); + } + if (measurements.length) { + series.push( + { label: "BT", color: "#60a5fa", values: bt }, + { label: "ET", color: "#f87171", values: et }, + { label: "Setpoint", color: "#34d399", values: setpoint }, + { label: "Fan % (x3)", color: "#38bdf8", values: fan.map((v) => v * 3) }, + { label: "Heater % (x3)", color: "#fb923c", values: heater.map((v) => v * 3) }, + { label: "BT RoR (x5)", color: "#22c55e", values: btRor.map((v) => (v == null ? null : Math.max(v, 0) * 5)) }, + { label: "ET RoR (x5)", color: "#a855f7", values: etRor.map((v) => (v == null ? null : Math.max(v, 0) * 5)) }, + ); + } + + return ( + + ); +} + +export function AutotuneGraph({ + history, + target, + setpoint, +}: { + history: Array<{ ET: number; BT: number; simBT: number }>; + target: "BT" | "ET" | "simBT"; + setpoint: number; +}) { + const values = history.map((s) => (target === "ET" ? s.ET : target === "simBT" ? s.simBT : s.BT)); + const samples = values.map((_, i) => i); + + return ( + setpoint) }, + ]} + /> + ); +} diff --git a/miniweb/src/logs.tsx b/miniweb/src/logs.tsx new file mode 100644 index 0000000..cfb302b --- /dev/null +++ b/miniweb/src/logs.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "preact/hooks"; +import { getBasicAuthHeaderValue } from "./auth"; + +export function LogsApp() { + const [logText, setLogText] = useState(""); + const [logError, setLogError] = useState(""); + const [csrfToken, setCsrfToken] = useState(""); + + const fetchCsrfToken = async () => { + const response = await fetch(`http://${location.host}/api/info`); + if (!response.ok) throw new Error(`Failed to load CSRF token: ${response.status}`); + const info = (await response.json()) as { csrfToken?: string }; + setCsrfToken(info.csrfToken || ""); + return info.csrfToken || ""; + }; + + const refreshLogs = async () => { + try { + setLogError(""); + const response = await fetch(`http://${location.host}/api/logs`); + if (!response.ok) throw new Error(`Failed to fetch logs: ${response.status}`); + setLogText(await response.text()); + } catch (error) { + setLogError(error instanceof Error ? error.message : "Unknown log error"); + } + }; + + useEffect(() => { + void fetchCsrfToken(); + void refreshLogs(); + const timer = window.setInterval(() => void refreshLogs(), 2000); + return () => window.clearInterval(timer); + }, []); + + const clearLogs = async () => { + try { + const token = csrfToken || (await fetchCsrfToken()); + const response = await fetch(`http://${location.host}/api/logs`, { + method: "DELETE", + headers: { Authorization: getBasicAuthHeaderValue(), "X-Yaeger-CSRF": token }, + }); + if (!response.ok) throw new Error(`Clear failed: ${response.status}`); + await refreshLogs(); + } catch (error) { + setLogError(error instanceof Error ? error.message : "Unknown clear error"); + } + }; + + const uploadLogFile = async (file: File) => { + try { + const token = csrfToken || (await fetchCsrfToken()); + const body = await file.text(); + const response = await fetch(`http://${location.host}/api/logs/upload`, { + method: "POST", + headers: { + Authorization: getBasicAuthHeaderValue(), + "X-Yaeger-CSRF": token, + "Content-Type": "text/plain", + }, + body, + }); + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + await refreshLogs(); + } catch (error) { + setLogError(error instanceof Error ? error.message : "Unknown upload error"); + } + }; + + return ( +
+

Yaeger Logs

+

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

+ {logError ?

Log error: {logError}

: null} +
+
+ + + +
+
+
+

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

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