diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78e3d53..dafefa8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,125 +1,136 @@ -name: CI - -on: - push: - branches: [ main ] - paths-ignore: - - 'custom_components/**' - - '.github/workflows/release.yml' - - '.github/workflows/release-ha.yml' - - '.github/workflows/prerelease.yml' - - '.github/workflows/slash-command.yml' - - '**/*.md' - - 'docs/**' - - 'LICENSE' - - '.gitignore' - - '.gitattributes' - - 'hacs.json' - pull_request: - paths-ignore: - - 'custom_components/**' - - '.github/workflows/release.yml' - - '.github/workflows/release-ha.yml' - - '.github/workflows/prerelease.yml' - - '.github/workflows/slash-command.yml' - - '**/*.md' - - 'docs/**' - - 'LICENSE' - - '.gitignore' - - '.gitattributes' - - 'hacs.json' - -permissions: - contents: read - -jobs: - firmware: - name: Firmware (${{ matrix.env }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - env: [c3-debug, s3-debug, esp32-debug] - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Build frontend (generates web_assets.h) - run: npm ci && npm run build - working-directory: frontend - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Cache PlatformIO - uses: actions/cache@v4 - with: - path: | - ~/.platformio/platforms - ~/.platformio/packages - key: platformio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }} - - - name: Install PlatformIO - # intelhex is an unlisted runtime dep of the newer tool-esptoolpy - # bundled with pioarduino 54.x; esptool imports it at module load. - run: pip install platformio intelhex - - - name: Build firmware - run: pio run -e ${{ matrix.env }} - - - name: Format check (clang-format) - run: python scripts/check_format.py - - - name: Static analysis (clang-tidy) - run: pio check -e ${{ matrix.env }} --fail-on-defect=low - - frontend: - name: Frontend - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: frontend/package-lock.json - - - name: Install dependencies - run: npm ci - working-directory: frontend - - - name: Lint and format check - run: npm run check - working-directory: frontend - - - name: Build (lint + typecheck + vite + embed) - run: npm run build - working-directory: frontend - - flash-tool: - name: Flash Tool - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version-file: flash/go.mod - - - name: Lint - uses: golangci/golangci-lint-action@v7 - with: - version: v2.11.3 - args: ./... - working-directory: flash - - - name: Build - run: go build -o openneato-flash . - working-directory: flash +name: CI + +on: + push: + branches: [ main ] + paths-ignore: + - 'custom_components/**' + - '.github/workflows/release.yml' + - '.github/workflows/release-ha.yml' + - '.github/workflows/prerelease.yml' + - '.github/workflows/slash-command.yml' + - '**/*.md' + - 'docs/**' + - 'LICENSE' + - '.gitignore' + - '.gitattributes' + - 'hacs.json' + pull_request: + paths-ignore: + - 'custom_components/**' + - '.github/workflows/release.yml' + - '.github/workflows/release-ha.yml' + - '.github/workflows/prerelease.yml' + - '.github/workflows/slash-command.yml' + - '**/*.md' + - 'docs/**' + - 'LICENSE' + - '.gitignore' + - '.gitattributes' + - 'hacs.json' + +permissions: + contents: read + +jobs: + firmware: + name: Firmware (${{ matrix.env }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + env: [c3-debug, c6-debug, c6-ota, s3-debug, esp32-debug] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend (generates web_assets.h) + run: npm ci && npm run build + working-directory: frontend + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Cache PlatformIO + uses: actions/cache@v4 + with: + path: | + ~/.platformio/platforms + ~/.platformio/packages + key: platformio-${{ matrix.env }}-${{ hashFiles('platformio.ini') }} + + - name: Install PlatformIO + # intelhex is an unlisted runtime dep of the newer tool-esptoolpy + # bundled with pioarduino 54.x; esptool imports it at module load. + run: pip install platformio intelhex + + - name: Build firmware + run: pio run -e ${{ matrix.env }} + + - name: Upload firmware binary + if: matrix.env == 'c6-ota' + uses: actions/upload-artifact@v4 + with: + name: firmware-${{ matrix.env }} + path: | + .pio/build/${{ matrix.env }}/firmware.bin + .pio/build/${{ matrix.env }}/partitions.bin + .pio/build/${{ matrix.env }}/bootloader.bin + retention-days: 7 + + - name: Format check (clang-format) + run: python scripts/check_format.py + + - name: Static analysis (clang-tidy) + run: pio check -e ${{ matrix.env }} --fail-on-defect=low + + frontend: + name: Frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Lint and format check + run: npm run check + working-directory: frontend + + - name: Build (lint + typecheck + vite + embed) + run: npm run build + working-directory: frontend + + flash-tool: + name: Flash Tool + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: flash/go.mod + + - name: Lint + uses: golangci/golangci-lint-action@v7 + with: + version: v2.11.3 + args: ./... + working-directory: flash + + - name: Build + run: go build -o openneato-flash . + working-directory: flash diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index c1759c0..d6c58d4 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -22,6 +22,7 @@ jobs: pr_number: ${{ steps.resolve.outputs.pr_number }} head_sha: ${{ steps.resolve.outputs.head_sha }} head_ref: ${{ steps.resolve.outputs.head_ref }} + checkout_ref: ${{ steps.resolve.outputs.checkout_ref }} release_tag: ${{ steps.resolve.outputs.tag }} version: ${{ steps.resolve.outputs.version }} @@ -37,9 +38,11 @@ jobs: PR_JSON=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid,headRefName) HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid') HEAD_REF=$(echo "$PR_JSON" | jq -r '.headRefName') + CHECKOUT_REF="refs/pull/$PR_NUMBER/head" else HEAD_SHA="$GITHUB_SHA" HEAD_REF="$GITHUB_REF_NAME" + CHECKOUT_REF="$GITHUB_SHA" fi LATEST_TAG=$(gh release list --repo "$GITHUB_REPOSITORY" \ @@ -51,15 +54,18 @@ jobs: if [ -n "$PR_NUMBER" ]; then TAG="v${BASE}-pr${PR_NUMBER}.${SHORT_SHA}" else - SAFE_REF=$(echo "$HEAD_REF" | sed 's/[^a-zA-Z0-9._-]/-/g') + SAFE_REF="${HEAD_REF//[^a-zA-Z0-9._-]/-}" TAG="v${BASE}-${SAFE_REF}.${SHORT_SHA}" fi - echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT" - echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + { + echo "pr_number=$PR_NUMBER" + echo "head_sha=$HEAD_SHA" + echo "head_ref=$HEAD_REF" + echo "checkout_ref=$CHECKOUT_REF" + echo "tag=$TAG" + echo "version=${TAG#v}" + } >> "$GITHUB_OUTPUT" echo "Release: $TAG ($HEAD_REF @ $SHORT_SHA)" prerelease: @@ -71,7 +77,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ needs.resolve.outputs.head_sha }} + ref: ${{ needs.resolve.outputs.checkout_ref }} fetch-depth: 0 - name: Create release tag @@ -122,10 +128,10 @@ jobs: env: FIRMWARE_VERSION: ${{ needs.resolve.outputs.version }} run: | - for env in $(grep -oE '^\[env:([a-zA-Z0-9_-]+-release)\]' platformio.ini | sed 's/\[env:\(.*\)\]/\1/'); do + while IFS= read -r env; do echo "Building $env..." pio run -e "$env" - done + done < <(grep -oE '^\[env:([a-zA-Z0-9_-]+-release)\]' platformio.ini | sed 's/\[env:\(.*\)\]/\1/') # --- Flash tool + GitHub Release --- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15a1ff7..884b341 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: run: | RELEASE_TAG="${{ github.event.inputs.release_tag }}" VERSION=${RELEASE_TAG#v} - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Setting version to: $VERSION" - name: Save release notes @@ -98,7 +98,7 @@ jobs: run: npm ci working-directory: frontend - - name: Build frontend (lint + typecheck + vite + embed) + - name: Build frontend (lint + codegen + path-sync + typecheck + vite + embed) run: npm run build working-directory: frontend @@ -128,10 +128,10 @@ jobs: run: | # Build all *-release environments; post-build hook packages # firmware artifacts into each env's build dir for GoReleaser - for env in $(grep -oE '^\[env:([a-zA-Z0-9_-]+-release)\]' platformio.ini | sed 's/\[env:\(.*\)\]/\1/'); do + while IFS= read -r env; do echo "Building $env..." pio run -e "$env" - done + done < <(grep -oE '^\[env:([a-zA-Z0-9_-]+-release)\]' platformio.ini | sed 's/\[env:\(.*\)\]/\1/') # --- Flash tool + GitHub Release --- diff --git a/.github/workflows/slash-command.yml b/.github/workflows/slash-command.yml index 95398d1..94952da 100644 --- a/.github/workflows/slash-command.yml +++ b/.github/workflows/slash-command.yml @@ -28,8 +28,7 @@ jobs: -f content='rocket' --silent PR_NUMBER="${{ github.event.issue.number }}" - HEAD_REF=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefName --jq '.headRefName') - gh workflow run prerelease.yml --repo "$GITHUB_REPOSITORY" --ref "$HEAD_REF" \ + gh workflow run prerelease.yml --repo "$GITHUB_REPOSITORY" --ref "${{ github.event.repository.default_branch }}" \ -f pr_number="$PR_NUMBER" \ -f comment_id="${{ github.event.comment.id }}" diff --git a/.gitignore b/.gitignore index ddd21ec..8c96012 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,14 @@ .pio .cache +__pycache__/ +*.pyc +compile_commands.json firmware/compile_commands.json firmware/src/web_assets.h frontend/node_modules frontend/dist +frontend/mock/build-info.js +frontend/src/types.generated.ts flash/openneato-flash release/ dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 31cb9d6..d330d06 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,6 +5,13 @@ project_name: openneato env: - ESPTOOL_VERSION=v5.2.0 +before: + # Stamp the release version into the OpenAPI spec shipped as a release + # artifact. The in-repo file carries "0.0.0-dev" as a placeholder. + hooks: + - "sed -i.bak 's/^ version:.*/ version: {{ .Version }}/' frontend/api/openapi.yaml" + - "rm -f frontend/api/openapi.yaml.bak" + builds: - id: openneato-flash dir: flash @@ -16,6 +23,16 @@ builds: env: - >- {{- if eq .Os "darwin" }}CGO_ENABLED=1{{- else }}CGO_ENABLED=0{{- end }} + # Pin macOS minimum to 11.0 (Big Sur), which is also Go's own floor as of + # Go 1.25+. Without this, the linker uses the build host's SDK as the + # deployment target, producing binaries that fail to load on anything + # older than the runner's macOS version. + - >- + {{- if eq .Os "darwin" }}MACOSX_DEPLOYMENT_TARGET=11.0{{- end }} + - >- + {{- if eq .Os "darwin" }}CGO_CFLAGS=-mmacosx-version-min=11.0{{- end }} + - >- + {{- if eq .Os "darwin" }}CGO_LDFLAGS=-mmacosx-version-min=11.0{{- end }} goos: - linux - darwin @@ -43,12 +60,14 @@ release: extra_files: - glob: .pio/build/*-release/*-firmware.bin - glob: .pio/build/*-release/*-full.tar.gz + - glob: frontend/api/openapi.yaml checksum: name_template: checksums.txt extra_files: - glob: .pio/build/*-release/*-firmware.bin - glob: .pio/build/*-release/*-full.tar.gz + - glob: frontend/api/openapi.yaml changelog: sort: asc diff --git a/AGENTS.md b/AGENTS.md index d55b2be..15b6634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,9 @@ Three top-level components: `firmware/` (ESP32 C/C++), `frontend/` (Preact SPA), - Flash tool: Go CLI, cross-compiled via GoReleaser, uses esptool subprocess - Mock server: `frontend/mock/server.js` Vite plugin, `SCENARIO` constant for state switching. Reset to `"ok"` before committing. -- Build pipeline: `npm run build` -> lint -> tsc -> vite -> `embed_frontend.js` generates `web_assets.h` +- Build pipeline: `npm run build` -> lint -> `jscpd` duplicate check -> `openapi-typescript` regenerates `src/types.generated.ts` from + `frontend/api/openapi.yaml` -> `check_api_paths.js` enforces firmware↔spec route parity -> tsc -> vite -> + `embed_frontend.js` generates `web_assets.h`. `frontend/api/openapi.yaml` ships as a release asset. ### Data Logging @@ -43,6 +45,13 @@ needs logging, add a new typed helper following the existing pattern. Log both success and failure outcomes. At info level, only failures and state transitions are logged; at debug level, all serial commands including raw responses are included. +### API Documentation + +`frontend/api/openapi.yaml` is the single source of truth for the HTTP API. +When adding/changing a route, edit it alongside `web_server.cpp` (the build +fails if paths drift). Frontend types are generated from it; frontend-only +types live in `frontend/src/types.ts`. + ### Filesystem and Flash Wear SPIFFS (not LittleFS). Buffer writes in RAM, never write to flash in a loop. @@ -68,13 +77,17 @@ Verify frontend changes: `npm run check` + `npm run build` in `frontend/`. Verify flash tool changes: `golangci-lint run ./...` + `go build` in `flash/`. +Verify GitHub Actions workflow changes: install `actionlint` if missing, then run +`actionlint` from the repository root. + ### Frontend ```bash cd frontend npm run dev # Vite dev server with mock API -npm run build # Lint + type check + build + embed into firmware header +npm run build # Lint + duplicate check + type check + build + embed into firmware header npm run check # Biome lint/format check +npm run dupes # Duplicate code check via jscpd npm run fix # Auto-fix safe issues ``` @@ -109,6 +122,9 @@ CSS frameworks, routing, or HTTP wrapper libraries. - 4-space indent, double quotes, semicolons, 120-col (Biome) - Named `interface`/`type` only, never inline object type literals +- Reuse existing CSS utilities and component classes before adding new ones. + When two rules share a body, consolidate via a shared selector list, a + shared class, or a CSS custom property. ## Hardware diff --git a/README.md b/README.md index e0f4186..8abb4d7 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,40 @@ -[![CI](https://github.com/renjfk/OpenNeato/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/renjfk/OpenNeato/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![Latest Release](https://img.shields.io/github/v/release/renjfk/OpenNeato)](https://github.com/renjfk/OpenNeato/releases/latest) -[![Downloads](https://img.shields.io/github/downloads/renjfk/OpenNeato/total)](https://github.com/renjfk/OpenNeato/releases) +[![Latest Release](https://img.shields.io/github/v/release/Leicas/OpenNeato)](https://github.com/Leicas/OpenNeato/releases/latest) +[![Upstream](https://img.shields.io/badge/upstream-renjfk%2FOpenNeato-blue)](https://github.com/renjfk/OpenNeato)

OpenNeato Icon

-# OpenNeato +# OpenNeato — Home Assistant fork -Open-source replacement for Neato's discontinued cloud and mobile app. An ESP32 bridge communicates with -Botvac robots (D3-D7) over UART and exposes a local web UI over WiFi — no cloud, no app, no account required. - -> [!NOTE] -> This is an early beta - things may break, rough edges are expected, and the API may change. -> If you run into problems, a [Discussion](https://github.com/renjfk/OpenNeato/discussions) -> or [issue](https://github.com/renjfk/OpenNeato/issues) is always welcome. +**This is a [Leicas/OpenNeato](https://github.com/Leicas/OpenNeato) fork focused on the Home Assistant +integration.** The HACS-installable integration under +[`custom_components/openneato/`](custom_components/openneato/) is the reason this fork exists — it turns the +upstream ESP32 bridge into a full HA device with vacuum / camera / sensor / switch / button entities, no YAML. > [!IMPORTANT] -> **Now in development:** -[Guided Clean - zone cleaning, no-go lines, and map-based navigation](https://github.com/renjfk/OpenNeato/issues/68). +> **If you want the standalone web UI**, use upstream [renjfk/OpenNeato](https://github.com/renjfk/OpenNeato) +> directly — it has the original maintainer, the release cadence, and the live demo. > -> Select zones on a previously recorded map, draw no-go lines, and let the robot clean exactly where you want. -> Follow the issue for progress updates and sub-task tracking. +> **Use this fork if** you run Home Assistant and want the robot exposed as a first-class HA device. Firmware +> changes in this fork are minor and exist only to support the integration (e.g. `/api/sensors`, +> `/api/history` hardening, ntfy server/token/on-start extensions). The firmware, frontend, and flash tool +> otherwise track upstream closely. + +The upstream project is an open-source replacement for Neato's discontinued cloud and mobile app: an ESP32 +bridge that talks to Botvac robots (D3-D7) over UART and exposes a local web UI over WiFi — no cloud, no app, +no account required. + +> [!NOTE] +> Both upstream and this fork are early beta. Rough edges are expected. For HA-integration bugs, open an +> [issue here](https://github.com/Leicas/OpenNeato/issues); for firmware/frontend/flash-tool bugs, file +> against [upstream](https://github.com/renjfk/OpenNeato/issues). + +> [!TIP] +> Want to get a feel for the underlying web UI without hardware? Open the upstream +> [live demo](https://openneato-demo.renjfk.com/). Demo states can be selected with `?scenario=...`; see +> [mock scenarios](docs/mock-scenarios.md). | Dashboard | Manual Drive | Cleaning History | |:----------------------------------------:|:----------------------------------------------:|:------------------------------------------------------:| @@ -32,13 +44,19 @@ Botvac robots (D3-D7) over UART and exposes a local web UI over WiFi — no clou |:----------------------------------------:|:--------------------------------------:|:--------------------------------------:| | ![Clean Map](screenshots/clean-map.webp) | ![Schedule](screenshots/schedule.webp) | ![Settings](screenshots/settings.webp) | -## Motivation +## About the underlying project (upstream) + +The sections below describe the upstream [renjfk/OpenNeato](https://github.com/renjfk/OpenNeato) project that +this fork builds on — the ESP32 bridge firmware, standalone web UI, and flash tool. **None of this is unique +to this fork**; it's reproduced here so HA users have full context on what's running behind the integration. +If you only want the HA bits, you can skip ahead to the [Home Assistant section](#home-assistant-integration) +below. Neato shut down their cloud services and mobile app, leaving perfectly functional robot vacuums without remote control or scheduling. OpenNeato brings them back to life with a small ESP32 board wired to the robot's debug port, giving you a local web interface that works without any external dependencies. -## Features +### Upstream web UI features - **Dashboard** with live robot status, battery level, cleaning state, WiFi signal, and storage usage - **House and spot cleaning** with pause/resume/stop/dock controls that adapt to the current state @@ -55,8 +73,8 @@ port, giving you a local web interface that works without any external dependenc - **OTA firmware updates** from the browser with SHA-256 download verification (against published `checksums.txt`), MD5 transfer integrity, dual-partition layout with auto-rollback, and automatic new version notifications when a release is available on GitHub -- **Settings page** for hostname, timezone, motor presets, notification topics, UART pins, theme (dark/light/auto), and - more +- **Settings page** for hostname, timezone, motor presets, notification topics, UART pins, theme (dark/light/auto), + battery diagnostics (cycle count, voltage, temperature, and a "New Battery" calibration trigger), and more - **Event logging** with configurable log levels (off/info/debug), compressed JSONL files on SPIFFS, browsable and downloadable from the UI; optional remote syslog (UDP) for long-running diagnostics without flash wear; logging is off by default @@ -71,6 +89,90 @@ port, giving you a local web interface that works without any external dependenc The frontend is a lightweight SPA that gets gzipped and embedded directly into the firmware binary, so a single OTA update covers both firmware and UI. Mobile-friendly, dark theme by default. +## Home Assistant Integration + +This fork ships a HACS-installable Home Assistant custom integration in +[`custom_components/openneato/`](custom_components/openneato/). Once installed it discovers your OpenNeato bridge +by IP/hostname and exposes the robot as a full HA device — no YAML, no extra add-ons. + +> [!NOTE] +> The HA integration is the primary differentiator of this fork. The firmware, frontend, and flash tool track +> upstream [`renjfk/OpenNeato`](https://github.com/renjfk/OpenNeato) closely. If you only want the standalone web +> UI, use upstream directly. + +### Install via HACS + +1. In HACS, add this fork as a **custom repository**: `https://github.com/Leicas/OpenNeato` (category: + *Integration*). +2. Search for **OpenNeato** in HACS and install. +3. Restart Home Assistant. +4. **Settings → Devices & Services → Add Integration → OpenNeato** and enter the bridge hostname or IP + (e.g. `neato.local` or `192.168.1.42`). + +The integration polls `/api/*` over your LAN every 5 seconds (`local_polling`). No cloud round-trip, no +external dependencies. Requires firmware `1.0+`; battery diagnostics need firmware `0.13+` (upstream PR #121). + +### What you get + +A single device with the following entity groups: + +- **Vacuum** (`vacuum.openneato_`) — start/stop/pause/dock/locate/spot-clean, battery level, status, + fan speed presets (Eco/Auto/Intense), error reporting. Works with all the standard vacuum cards. +- **Cameras** — `LIDAR map` (live 360° scan during cleaning) and `Cleaning replay` (animated GIF time-lapse + of the most recent completed session). Both are standard HA camera entities, compatible with + picture-entity, picture-glance, and vacuum-card. +- **Sensors** — battery level/voltage/current/temperature, battery cycle count, cumulative cleaning time, + WiFi RSSI, free heap, storage used, uptime, motor RPMs, error code/message, plus "last clean" stats + (duration, area covered, distance, battery used, mode, end time) pulled from the on-device history. +- **Binary sensors** — charging, external power, battery over-temp, battery failure, empty fuel, error + active, NTP synced, dustbin seated, left/right wheel lifted, dock contact. +- **Switches** — eco mode, intense clean, bin-full detect, wall follower, schedule on/off, button-click + sounds, melodies, warnings, stealth LED, remote syslog, WiFi AP fallback, and per-event push + notifications (start/done/error/alert/docking). +- **Text** — syslog server IP, ntfy topic, ntfy server, ntfy token (full push-notification config from HA). +- **Numbers** — brush RPM, vacuum speed, side-brush power, stall threshold. +- **Select** — navigation mode (Normal / Gentle / Deep / Quick). +- **Buttons** — restart bridge, restart robot, shutdown robot, locate, clear errors, format filesystem + (diagnostic, disabled by default), **new battery** (resets fuel-gauge calibration after a physical pack + swap, disabled by default for safety). + +Every entity is translated via `strings.json`, and diagnostic-class entities (voltages, currents, raw +sensor states) are tagged so they cluster cleanly under HA's Diagnostic section. + +### Notes for setup + +- **Camera entities and vacuum-card** — the `LIDAR map` camera self-manages a 2 s `/api/lidar` poll only + while the robot is actively cleaning, so it doesn't add load to the coordinator's 5 s cycle. When idle, + both cameras fall back to the most recent completed cleaning map. +- **Coordinator resilience** — the integration tolerates a single hung endpoint without going into + "requires attention" state. State / charger / system are critical; anything else (errors, motors, + history) falls back to the last known value during transient ESP32 serial hangs. +- **No Pillow declared dependency** — map rendering uses Pillow which already ships with HA Core, so the + integration's `manifest.json` keeps `"requirements": []`. Nothing extra to install. +- **ntfy + custom servers** — point `ntfy_server` at a self-hosted instance and `ntfy_token` at a Bearer + token for authenticated push. Empty server defaults to `ntfy.sh`; empty token is unauthenticated. + +### Version history + +Full per-version notes live in [`custom_components/openneato/CHANGELOG.md`](custom_components/openneato/CHANGELOG.md). +Highlights: + +- **1.11** — added `notify_on_start` and `ap_fallback_on_disconnect` switches; ntfy topic/server/token text + entities for full HA-side push config. +- **1.10** — battery diagnostics (current, voltage, cycles, cumulative cleaning time) on top of firmware + PR #121, `New battery` calibration button, UTF-8-tolerant `/api/version` parsing. +- **1.9** — fixed cameras stuck on the idle placeholder (`get_encoding()` crash on streamed bodies). +- **1.6** — `Cleaning replay` camera (animated GIF time-lapse), `/api/history` corruption-tolerant parsing, + history filename validation + 2 MB response cap (LAN-MITM mitigation). +- **1.3** — `LIDAR map` camera ported from the frontend renderer; self-managed polling. +- **1.2** — last-clean stats sensors; dropped Pillow from declared deps. + +### Reporting integration bugs + +Use this fork's [issue tracker](https://github.com/Leicas/OpenNeato/issues) for anything that lives under +`custom_components/openneato/`. For firmware, frontend, or flash-tool issues, upstream +[renjfk/OpenNeato](https://github.com/renjfk/OpenNeato/issues) is the right place. + ## Supported Robots Neato Botvac D3 through D7. D8/D9/D10 are NOT supported (different board, password-locked serial port). @@ -166,6 +268,10 @@ When creating issues, please follow our simple naming convention: ## Development +For frontend development without hardware, use the mock API scenarios documented in +[`docs/mock-scenarios.md`](docs/mock-scenarios.md). The same `?scenario=...` URLs work in local Vite dev and the +Cloudflare demo. + ### Release Process Manual releases via opencode; see [RELEASE_PROCESS.md](RELEASE_PROCESS.md). diff --git a/custom_components/openneato/CHANGELOG.md b/custom_components/openneato/CHANGELOG.md index d20a60b..aa3181d 100644 --- a/custom_components/openneato/CHANGELOG.md +++ b/custom_components/openneato/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## 1.11 + +### Added +- **`notify_on_start` switch** — surfaces fork PR #2's "notify on cleaning + started" setting (`ntfyOnStart`). Firmware + frontend already supported + the toggle; the matching HA entity was missing, so the switch row sat + among `notify_on_done`/`error`/`alert`/`docking` with no `start` peer. +- **`ap_fallback_on_disconnect` switch** — upstream firmware #110 added + an `apFallbackOnDisconnect` setting that brings up the captive-portal + AP automatically when WiFi STA drops. Now configurable from HA. +- **`ntfy_topic`, `ntfy_server`, `ntfy_token` text entities** — full ntfy + push-notification configuration is now editable from HA, no need to + open the device web UI. Empty topic disables notifications; empty + server defaults to `ntfy.sh`; empty token is unauthenticated. + +### Not done (deliberate deferral) +- **Upstream About metadata not surfaced** — firmware commit bb7b541 + added `name`, `repositoryUrl`, `license`, `model` fields to + `/api/firmware/version`. Threading `repository_url` through every + entity constructor for nine platforms (vacuum, sensor, switch, + binary_sensor, number, button, camera, select, text) just to set a + device-info attribute was too invasive for the value it adds, and the + `name` field already matches our hardcoded `manufacturer="OpenNeato"`. + A cleaner future approach is a dedicated diagnostic sensor that + exposes the full About payload as attributes. +- **Zeroconf discovery not added** — `manifest.json` was eligible for a + `zeroconf` entry, but the firmware (`wifi_manager.cpp`) currently only + registers the generic `_http._tcp` service. Claiming that service for + the OpenNeato integration would match every random HTTP device on the + LAN. When the firmware advertises a dedicated service type (e.g. + `_openneato._tcp`), wire this up. + +## 1.10 + +### Fixed +- **Setup crash with new firmware (`utf-8 codec can't decode byte 0xab`)** — + Upstream firmware PR #121 added `smartBattery*` string fields to + `/api/version` that pass raw bytes from the robot's smart-battery + memory through the firmware's `jsonEscape()` (it only escapes bytes + below 0x20). When the pack reports a non-UTF-8 byte, aiohttp's + `response.json()` raised `UnicodeDecodeError` and the config entry + failed setup. Responses are now decoded with `errors="replace"` + before being parsed as JSON so one bad glyph can't take the + integration down. + +### Added +- **Battery diagnostics from firmware PR #121** — three new diagnostic + sensors backed by the new `/api/analog` endpoint (battery voltage, + current, external voltage) and two backed by `/api/warranty` + (battery cycles, cumulative cleaning time). The existing + `Battery temperature` sensor was repointed from the now-removed + `/api/charger#battTempC` to `/api/analog#batteryTemperatureC` + (a finer-grained float in °C). A `New battery` button (disabled by + default) calls `POST /api/battery/new` to reset the fuel-gauge + calibration after a pack swap. + ## 1.9 ### Fixed diff --git a/custom_components/openneato/api.py b/custom_components/openneato/api.py index 05cfbd9..e0bf9c8 100644 --- a/custom_components/openneato/api.py +++ b/custom_components/openneato/api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import re from typing import Any @@ -28,6 +29,19 @@ class OpenNeatoApiError(HomeAssistantError): """Error to indicate a non-connection API failure.""" +async def _read_json(response: aiohttp.ClientResponse) -> Any: + """Read a response body and parse it as JSON, tolerating stray non-UTF-8 bytes. + + The firmware's jsonEscape() passes bytes >= 0x20 through verbatim, so the + /api/version response can contain raw bytes from the robot's smart-battery + memory (manufacturer name, serial, etc.) that aren't valid UTF-8. Replace + those bytes rather than raising — losing one glyph is better than failing + the whole config-entry setup. + """ + raw = await response.read() + return json.loads(raw.decode("utf-8", errors="replace")) + + class OpenNeatoApiClient: """Async HTTP client for OpenNeato.""" @@ -59,7 +73,7 @@ async def _get(self, path: str) -> dict[str, Any]: path, response.status, response.content_type, ) response.raise_for_status() - return await response.json() + return await _read_json(response) except aiohttp.ClientConnectionError as err: _LOGGER.warning("Connection error on GET %s: %s", path, err) raise OpenNeatoConnectionError( @@ -92,7 +106,7 @@ async def _post( response.raise_for_status() content_type = response.content_type or "" if "json" in content_type: - return await response.json() + return await _read_json(response) return await response.text() except aiohttp.ClientConnectionError as err: _LOGGER.warning("Connection error on POST %s: %s", path, err) @@ -122,7 +136,7 @@ async def _put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]: path, response.status, response.content_type, ) response.raise_for_status() - return await response.json() + return await _read_json(response) except aiohttp.ClientConnectionError as err: _LOGGER.warning("Connection error on PUT %s: %s", path, err) raise OpenNeatoConnectionError( @@ -149,6 +163,14 @@ async def get_charger(self) -> dict[str, Any]: """Get charger / battery information.""" return await self._get("/api/charger") + async def get_battery_analog(self) -> dict[str, Any]: + """Get analog battery readings (voltage, current, temperature).""" + return await self._get("/api/analog") + + async def get_battery_warranty(self) -> dict[str, Any]: + """Get battery warranty data (cumulative cycles, runtime).""" + return await self._get("/api/warranty") + async def get_error(self) -> dict[str, Any]: """Get current error information.""" return await self._get("/api/error") @@ -270,6 +292,10 @@ async def restart(self) -> dict[str, Any] | str: """Restart the robot controller.""" return await self._post("/api/system/restart") + async def new_battery(self) -> dict[str, Any] | str: + """Reset battery fuel-gauge calibration after physically replacing the pack.""" + return await self._post("/api/battery/new") + async def format_fs(self) -> dict[str, Any] | str: """Format the filesystem.""" return await self._post("/api/system/format-fs") diff --git a/custom_components/openneato/button.py b/custom_components/openneato/button.py index f3425b3..1baa60e 100644 --- a/custom_components/openneato/button.py +++ b/custom_components/openneato/button.py @@ -81,6 +81,17 @@ class OpenNeatoButtonEntityDescription(ButtonEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, press_fn=lambda api: api.clear_errors(), ), + OpenNeatoButtonEntityDescription( + # Resets the battery fuel-gauge calibration after a pack swap. + # Disabled by default — user must opt in to avoid accidental resets. + key="new_battery", + translation_key="new_battery", + name="New battery", + icon="mdi:battery-sync-outline", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + press_fn=lambda api: api.new_battery(), + ), ) diff --git a/custom_components/openneato/coordinator.py b/custom_components/openneato/coordinator.py index fcf7e4a..bbf9ca7 100644 --- a/custom_components/openneato/coordinator.py +++ b/custom_components/openneato/coordinator.py @@ -41,12 +41,15 @@ async def _async_update_data(self) -> dict[str, Any]: self.api.get_motors(), self.api.get_history(), self.api.get_sensors(), + self.api.get_battery_analog(), + self.api.get_battery_warranty(), return_exceptions=True, ) keys = ( "state", "charger", "error", "user_settings", "system", "settings", "motors", "history", "sensors", + "analog", "warranty", ) # Critical endpoints — if ALL of these fail we consider the robot # unreachable. Non-critical endpoints (like /api/error, which can hang diff --git a/custom_components/openneato/manifest.json b/custom_components/openneato/manifest.json index 6c67297..e781d26 100644 --- a/custom_components/openneato/manifest.json +++ b/custom_components/openneato/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://github.com/renjfk/OpenNeato", "iot_class": "local_polling", "requirements": [], - "version": "1.9" + "version": "1.11" } diff --git a/custom_components/openneato/sensor.py b/custom_components/openneato/sensor.py index 03a81c4..f50a5e9 100644 --- a/custom_components/openneato/sensor.py +++ b/custom_components/openneato/sensor.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, + UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfLength, UnitOfTemperature, @@ -80,16 +81,17 @@ class OpenNeatoSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), OpenNeatoSensorEntityDescription( + # PR #121 moved battery temp out of /api/charger into the new + # /api/analog endpoint as a float in C (was an int with -1 sentinel). key="charger_batt_temp", translation_key="battery_temperature", name="Battery temperature", - section="charger", - field="battTempC", + section="analog", + field="batteryTemperatureC", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda v: v if v is not None and v >= 0 else None, ), OpenNeatoSensorEntityDescription( key="charger_discharge_mah", @@ -113,6 +115,65 @@ class OpenNeatoSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + # ── Analog battery readings (PR #121, /api/analog) ────────────────── + OpenNeatoSensorEntityDescription( + key="analog_battery_voltage", + translation_key="analog_battery_voltage", + name="Battery voltage (analog)", + section="analog", + field="batteryVoltageV", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + OpenNeatoSensorEntityDescription( + key="analog_battery_current", + translation_key="analog_battery_current", + name="Battery current", + section="analog", + field="batteryCurrentMA", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OpenNeatoSensorEntityDescription( + key="analog_external_voltage", + translation_key="analog_external_voltage", + name="External voltage (analog)", + section="analog", + field="externalVoltageV", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + # ── Battery warranty (PR #121, /api/warranty) ─────────────────────── + OpenNeatoSensorEntityDescription( + key="warranty_battery_cycles", + translation_key="battery_cycles", + name="Battery cycles", + section="warranty", + field="cumulativeBatteryCycles", + icon="mdi:battery-sync", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + OpenNeatoSensorEntityDescription( + key="warranty_cleaning_time", + translation_key="cumulative_cleaning_time", + name="Cumulative cleaning time", + section="warranty", + field="cumulativeCleaningTimeSeconds", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + icon="mdi:timer-sand", + entity_category=EntityCategory.DIAGNOSTIC, + ), # ── Error ─────────────────────────────────────────────────────────── OpenNeatoSensorEntityDescription( key="error_code", diff --git a/custom_components/openneato/strings.json b/custom_components/openneato/strings.json index 9f04d88..9684e3b 100644 --- a/custom_components/openneato/strings.json +++ b/custom_components/openneato/strings.json @@ -40,7 +40,12 @@ "last_clean_distance": { "name": "Last clean distance" }, "last_clean_battery_used": { "name": "Last clean battery used" }, "last_clean_mode": { "name": "Last clean mode" }, - "last_clean_ended": { "name": "Last clean ended" } + "last_clean_ended": { "name": "Last clean ended" }, + "analog_battery_voltage": { "name": "Battery voltage (analog)" }, + "analog_battery_current": { "name": "Battery current" }, + "analog_external_voltage": { "name": "External voltage (analog)" }, + "battery_cycles": { "name": "Battery cycles" }, + "cumulative_cleaning_time": { "name": "Cumulative cleaning time" } }, "binary_sensor": { "charging": { "name": "Charging" }, @@ -66,10 +71,12 @@ "warning_sounds": { "name": "Warning sounds" }, "stealth_led": { "name": "Stealth LED" }, "notifications_enabled": { "name": "Notifications" }, + "notify_on_start": { "name": "Notify on clean start" }, "notify_on_done": { "name": "Notify on clean done" }, "notify_on_error": { "name": "Notify on error" }, "notify_on_alert": { "name": "Notify on alert" }, "notify_on_docking": { "name": "Notify on docking" }, + "ap_fallback_on_disconnect": { "name": "WiFi AP fallback" }, "remote_syslog": { "name": "Remote syslog" } }, "button": { @@ -78,7 +85,8 @@ "robot_restart": { "name": "Restart robot" }, "robot_shutdown": { "name": "Shutdown robot" }, "locate": { "name": "Locate robot" }, - "clear_errors": { "name": "Clear errors" } + "clear_errors": { "name": "Clear errors" }, + "new_battery": { "name": "New battery" } }, "number": { "brush_rpm_setting": { "name": "Brush RPM" }, @@ -94,7 +102,10 @@ "navigation_mode": { "name": "Navigation mode" } }, "text": { - "syslog_ip": { "name": "Syslog server IP" } + "syslog_ip": { "name": "Syslog server IP" }, + "ntfy_topic": { "name": "Notification topic" }, + "ntfy_server": { "name": "Notification server" }, + "ntfy_token": { "name": "Notification token" } } } } diff --git a/custom_components/openneato/switch.py b/custom_components/openneato/switch.py index 5066bf1..16b4fce 100644 --- a/custom_components/openneato/switch.py +++ b/custom_components/openneato/switch.py @@ -131,6 +131,16 @@ class OpenNeatoSwitchEntityDescription(SwitchEntityDescription): icon="mdi:bell", entity_category=EntityCategory.CONFIG, ), + OpenNeatoSwitchEntityDescription( + key="notify_on_start", + translation_key="notify_on_start", + name="Notify on clean start", + section="settings", + field="ntfyOnStart", + settings_field="ntfyOnStart", + icon="mdi:bell-outline", + entity_category=EntityCategory.CONFIG, + ), OpenNeatoSwitchEntityDescription( key="notify_on_done", translation_key="notify_on_done", @@ -171,6 +181,16 @@ class OpenNeatoSwitchEntityDescription(SwitchEntityDescription): icon="mdi:bell-plus", entity_category=EntityCategory.CONFIG, ), + OpenNeatoSwitchEntityDescription( + key="ap_fallback_on_disconnect", + translation_key="ap_fallback_on_disconnect", + name="WiFi AP fallback", + section="settings", + field="apFallbackOnDisconnect", + settings_field="apFallbackOnDisconnect", + icon="mdi:wifi-strength-alert-outline", + entity_category=EntityCategory.CONFIG, + ), OpenNeatoSwitchEntityDescription( key="remote_syslog", translation_key="remote_syslog", diff --git a/custom_components/openneato/text.py b/custom_components/openneato/text.py index a1fabfe..e0e252a 100644 --- a/custom_components/openneato/text.py +++ b/custom_components/openneato/text.py @@ -40,6 +40,33 @@ class OpenNeatoTextEntityDescription(TextEntityDescription): native_max=15, pattern=r"^(\d{1,3}\.){3}\d{1,3}$|^$", ), + OpenNeatoTextEntityDescription( + key="ntfy_topic", + translation_key="ntfy_topic", + name="Notification topic", + section="settings", + settings_field="ntfyTopic", + icon="mdi:bell-cog", + entity_category=EntityCategory.CONFIG, + ), + OpenNeatoTextEntityDescription( + key="ntfy_server", + translation_key="ntfy_server", + name="Notification server", + section="settings", + settings_field="ntfyServer", + icon="mdi:server", + entity_category=EntityCategory.CONFIG, + ), + OpenNeatoTextEntityDescription( + key="ntfy_token", + translation_key="ntfy_token", + name="Notification token", + section="settings", + settings_field="ntfyToken", + icon="mdi:key", + entity_category=EntityCategory.CONFIG, + ), ) diff --git a/docs/mock-scenarios.md b/docs/mock-scenarios.md new file mode 100644 index 0000000..4c985a6 --- /dev/null +++ b/docs/mock-scenarios.md @@ -0,0 +1,117 @@ +# Mock scenarios + +The frontend mock API supports scenario flags for testing robot states and failures without hardware. Scenarios are +selected with the `scenario` query parameter and can be combined with `|`. + +Useful combinations: + +- `ok` - robot idle, online, battery 85% +- `err|fa` - robot error, brush stuck, plus action faults +- `low|fl|fs` - low battery plus log faults and settings fault +- `man|llq` - manual clean plus low LIDAR quality + +When writing URLs, encode `|` as `%7C` if your browser or shell does not preserve it literally. + +## Local development + +Start the Vite dev server: + +```bash +cd frontend +npm run dev +``` + +Open the UI with a scenario: + +```text +http://localhost:5173/?scenario=err%7Cfa +http://localhost:5173/?scenario=low%7Cfl%7Cfs#/settings +``` + +The selected scenario is stored in the `openneato_scenario` cookie so later SPA API calls keep using it. Switch back to +the normal state with: + +```text +http://localhost:5173/?scenario=ok +``` + +## Demo site + +The Cloudflare demo uses the same mock API and the same query parameter: + +```text +https://openneato-demo.renjfk.com/?scenario=err%7Cfa +https://openneato-demo.renjfk.com/?scenario=man%7Cllq#/manual +``` + +The demo persists the selected scenario in the same `openneato_scenario` cookie. Firmware and history uploads are +blocked server-side in demo mode. + +## Scenario keys + +Robot state: + +- `ok` - idle, online, battery 85% +- `off` - device unreachable +- `ident` - identifying robot during boot +- `unsup` - unsupported robot model +- `upd` - firmware v0.9, triggers update banner +- `cls` - house cleaning +- `spt` - spot cleaning +- `dock` - docking, return to base +- `rchg` - mid-clean recharge, on dock and charging +- `chg` - charging, 62% +- `ch2` - charging, 25% +- `ful` - full, on dock +- `mid` - battery 45% +- `low` - battery 12% +- `ded` - battery 0% +- `err` - brush stuck error +- `alrt` - alert only, brush change + +Manual clean, combinable with each other or fault scenarios: + +- `man` - manual mode active, no safety issues +- `mlf` - manual mode active, robot lifted +- `mbf` - manual mode active, front-left bumper contact +- `mbs` - manual mode active, side-right bumper contact +- `msf` - manual mode active, forward stall, reverse to clear +- `msr` - manual mode active, rear stall, move forward to clear + +LIDAR quality, combinable with any state: + +- `llq` - low scan quality, fewer than 90 valid points +- `lsl` - slow LDS rotation, 2.8 Hz +- `lno` - LIDAR unavailable, `GET /api/lidar` returns an error + +Fault injection, combinable with any state: + +- `fa` - action faults, clean house, spot, stop, and return operations fail +- `fs` - settings fault, NVS write error +- `flr` - log read fault, list and content fail +- `fld` - log delete fault, delete single and delete all fail +- `fl` - log reads and deletes fail +- `fps` - `/api/state` polling fails +- `fpc` - `/api/charger` polling fails +- `fpe` - `/api/error` polling fails +- `fp` - all polling endpoints fail, state, charger, and error +- `fhc` - history corruption, injects corrupted pose lines in session data +- `fhl` - history list corruption, malformed JSON in `/api/history` response, triggers recovery panel +- `fal` - all major faults combined + +WiFi: + +- `wap` - fallback AP active, STA disconnected and fallback enabled +- `wnc` - no saved credentials, first boot path, AP always on +- `wfo` - fallback AP setting off, combine with `wap` to test off plus disconnected +- `fws` - scan fault, `/api/wifi/scan` returns 500 +- `fwn` - empty scan, `/api/wifi/scan` returns no networks +- `fwc` - connect fault, `/api/wifi/connect` rejects with auth error + +## Examples + +- `err|fa` - robot error plus action failures +- `low|fl|fs` - low battery, log failures, and settings failure +- `man|llq` - manual mode with degraded LIDAR +- `wap|wfo` - disconnected station with fallback AP setting disabled +- `upd|fhl` - update banner plus corrupted history list recovery state diff --git a/docs/user-guide.md b/docs/user-guide.md index 8040331..b8d8d42 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -6,7 +6,6 @@ Everything you need to set up, configure, and troubleshoot OpenNeato. - [Hardware Setup](#hardware-setup) - [What You Need](#what-you-need) - - [Opening the Robot](#opening-the-robot) - [Debug Port Pinout](#debug-port-pinout) - [Wiring](#wiring) - [Flashing Firmware](#flashing-firmware) @@ -14,16 +13,21 @@ Everything you need to set up, configure, and troubleshoot OpenNeato. - [Basic Usage](#basic-usage) - [What Happens Under the Hood](#what-happens-under-the-hood) - [Command Reference](#command-reference) + - [Manual Flashing with esptool](#manual-flashing-with-esptool) - [Troubleshooting Flash Issues](#troubleshooting-flash-issues) - [First-Time WiFi Setup](#first-time-wifi-setup) - - [Serial Monitor](#serial-monitor) + - [Option A: Fallback Access Point (no serial cable)](#option-a-fallback-access-point-no-serial-cable) + - [Option B: Serial Monitor](#option-b-serial-monitor) - [WiFi Configuration Menu](#wifi-configuration-menu) - [Verifying the Connection](#verifying-the-connection) - [Quick Commands](#quick-commands) + - [Reconfiguring WiFi Later](#reconfiguring-wifi-later) - [Troubleshooting](#troubleshooting) - [Enabling Logging](#enabling-logging) - [Collecting Logs](#collecting-logs) + - [Robot Stuck Starting a House Clean](#robot-stuck-starting-a-house-clean) - [Downloading Cleaning Maps](#downloading-cleaning-maps) + - [Recovering Corrupted Cleaning History](#recovering-corrupted-cleaning-history) - [Factory Reset](#factory-reset) - [Reporting an Issue](#reporting-an-issue) - [Multiple Robots](#multiple-robots) @@ -37,12 +41,10 @@ Everything you need to set up, configure, and troubleshoot OpenNeato. ## Hardware Setup > [!NOTE] -> Hardware assembly is not the primary focus of this project. There are already comprehensive -> teardown and wiring guides available — in particular -> [Philip2809/neato-brainslug](https://github.com/Philip2809/neato-brainslug) which covers -> the D3-D7 debug port in detail. This section shares my personal experience with minimal -> photos and a bill of materials. If there's community interest I'll expand it further — -> I'll be opening my own Neato D7 soon to replace the LIDAR O-ring. +> Hardware assembly is not the primary focus of this project. For a comprehensive teardown +> guide covering how to open the robot and reach the debug port, see +> [Philip2809/neato-brainslug](https://github.com/Philip2809/neato-brainslug). This section +> covers the parts list, debug port pinout, and wiring. ### What You Need @@ -75,11 +77,6 @@ solder the JST connector wires directly to the board pads for the cleanest resul The ESP32 is powered directly from the robot's 3.3V debug port — no separate USB power supply needed during normal operation. -### Opening the Robot - - - - ### Debug Port Pinout The debug port connector on Botvac D3-D7 has four pins (left to right when looking at the @@ -102,7 +99,7 @@ These are the **robot's** RX/TX labels, so you cross-connect to the ESP32: The default TX/RX GPIOs depend on the chip (ESP32-C3: GPIO 3/4, ESP32-S3: GPIO 17/18, original ESP32: GPIO 17/16) but are fully configurable from the web UI in -**Settings -> Robot -> UART Pins** — so wire whichever GPIOs are convenient and update the +**Settings -> Device -> UART Pins** — so wire whichever GPIOs are convenient and update the setting to match. ### Wiring @@ -155,6 +152,11 @@ These are standalone binaries — no extraction needed. On macOS/Linux you may n > xattr -d com.apple.quarantine ~/Downloads/openneato-flash_Darwin_arm64 > ``` +> [!IMPORTANT] +> macOS 11 (Big Sur) is the minimum supported version. This is the floor for the Go toolchain +> the binary is built with — older macOS versions (Catalina, Mojave, and earlier) will fail to +> load the binary or crash on first network request. + > [!WARNING] > The flash tool has been primarily tested on macOS. Linux and Windows builds are provided but > not battle-tested — if you run into issues on those platforms, please @@ -215,6 +217,108 @@ tool refuses to flash. > directory as the `.tar.gz` file. The tool verifies SHA-256 before extracting and will > refuse to flash if the checksums file is missing or the hash doesn't match. +### Manual Flashing with esptool + +If `openneato-flash` does not run on your system, you can flash the same firmware pack manually +with Espressif's prebuilt `esptool` binary. This path does not require Python and uses the same +files, offsets, baud rate, compression, and reset behavior as `openneato-flash`. + +1. Download the matching prebuilt `esptool` binary for your platform from + [Espressif's esptool releases](https://github.com/espressif/esptool/releases), then extract + it. To use the same version as `openneato-flash`, check `ESPTOOL_VERSION` in + [`.goreleaser.yml`](../.goreleaser.yml). + +2. Plug in the ESP32. If only one ESP device is connected, you can let `esptool` find the serial + port automatically by omitting `-p`. + + If you have multiple serial devices connected, pass the port explicitly with `-p`. Common port + names are `/dev/cu.usbmodem*` on macOS, `/dev/ttyUSB*` or `/dev/ttyACM*` on Linux, and `COM3` + or similar on Windows. + +3. Detect the chip name: + + ```bash + ./esptool chip-id + ``` + + Look for a line like `Detecting chip type... ESP32-C3`, then use the lowercase chip name in + the firmware filename: `esp32-c3`, `esp32-s3`, or `esp32`. + +4. Download the full firmware pack and `checksums.txt` from the + [OpenNeato Releases](https://github.com/renjfk/OpenNeato/releases) page. + + For example, for an ESP32-C3 on the latest release: + + ```bash + curl -LO https://github.com/renjfk/OpenNeato/releases/latest/download/openneato-esp32-c3-full.tar.gz + curl -LO https://github.com/renjfk/OpenNeato/releases/latest/download/checksums.txt + ``` + +5. Verify the firmware pack checksum before extracting it. + + macOS: + + ```bash + shasum -a 256 -c checksums.txt --ignore-missing + ``` + + Linux: + + ```bash + sha256sum -c checksums.txt --ignore-missing + ``` + + Windows PowerShell: + + ```powershell + Select-String "openneato-esp32-c3-full.tar.gz" checksums.txt + Get-FileHash .\openneato-esp32-c3-full.tar.gz -Algorithm SHA256 + ``` + + The hash printed by PowerShell must match the hash from `checksums.txt`. + +6. Extract the firmware pack and open `offsets.json`. + + macOS/Linux: + + ```bash + tar -xzf openneato-esp32-c3-full.tar.gz + ``` + + Windows PowerShell: + + ```powershell + tar -xzf .\openneato-esp32-c3-full.tar.gz + ``` + +7. Flash all images using the offsets from `offsets.json`. + + macOS/Linux: + + ```bash + ./esptool -b 921600 --before default-reset --after hard-reset write-flash -z \ + BOOTLOADER_OFFSET bootloader.bin \ + PARTITIONS_OFFSET partitions.bin \ + OTADATA_OFFSET boot_app0.bin \ + APP_OFFSET firmware.bin + ``` + + Windows PowerShell: + + ```powershell + .\esptool.exe -b 921600 --before default-reset --after hard-reset write-flash -z ` + BOOTLOADER_OFFSET .\bootloader.bin ` + PARTITIONS_OFFSET .\partitions.bin ` + OTADATA_OFFSET .\boot_app0.bin ` + APP_OFFSET .\firmware.bin + ``` + + Replace the `*_OFFSET` placeholders with the values from `offsets.json`, and replace the + firmware pack name to match your chip. If auto-detection picks the wrong serial device or you + have multiple ESP devices connected, add `-p ` before `-b 921600`. Do not hard-code the + flash offsets from another source; `offsets.json` is generated with the release and is the + source of truth for the manual command. + ### Troubleshooting Flash Issues **"No USB serial ports found"** — Make sure the ESP32 is plugged in and your OS recognizes @@ -234,23 +338,48 @@ Windows, close any other serial monitor that might have the port open. ## First-Time WiFi Setup -After flashing, the tool opens a serial monitor where you'll configure WiFi. +After flashing, the device has no saved WiFi credentials and won't be on your network yet. +You have two ways to provision it: a browser via the fallback access point (no serial cable +needed), or the serial menu that the flash tool opens for you. + +### Option A: Fallback Access Point (no serial cable) + +When the device has no saved credentials, it broadcasts an open WiFi network so you can +configure it from any phone or laptop browser. This works even after you've unplugged the +USB cable and tucked the ESP32 inside the robot. -### Serial Monitor +1. From your phone or laptop, connect to the WiFi network named **`neato-ap`** (or + `-ap` if you've changed the hostname). It's an open network , no password. +2. Open a browser and go to `http://192.168.4.1`. You'll land on the OpenNeato dashboard. +3. Open **Settings -> WiFi**, tap **Scan**, pick your home network from the dropdown, and + enter the password. +4. After confirming, the device joins your home network and reboots. The `neato-ap` network + disappears at that point , reconnect your phone/laptop to your home WiFi to keep using + the web UI at `http://neato.local` (or the IP shown on the dashboard). + +> [!NOTE] +> The fallback AP is unencrypted because there's no way to display a password to a user +> who hasn't set one up yet. It only runs while the device has no saved credentials or +> cannot reach your home network. Once connected, it shuts down automatically. + +### Option B: Serial Monitor The serial monitor connects at 115,200 baud and shows the ESP32's boot output. You'll see -the boot banner: +the boot banner. With no credentials saved, the fallback AP comes up automatically and the +banner reflects that: ``` ======================================== OpenNeato v0.1 ======================================== - WiFi: not configured + WiFi: AP mode, connect to neato-ap and open http://192.168.4.1 Press 'm' for menu, 's' for status ======================================== ``` -If WiFi is not configured, the configuration menu appears automatically. +You can finish provisioning either by joining `neato-ap` from a browser (Option A above) or +by pressing `m` to open the WiFi configuration menu over serial , both end up in the same +place. ### WiFi Configuration Menu @@ -311,6 +440,21 @@ Once connected, you can type single-key commands in the serial monitor at any ti | `m` | Open WiFi configuration menu | | `s` | Print WiFi status (SSID, IP, MAC, RSSI) | +### Reconfiguring WiFi Later + +Once the device is on your home network you can change networks from the web UI directly: +**Settings -> WiFi -> Scan**, pick a new network, enter the password. + +If your home network goes down or you've moved house, the bridge falls back to the +`-ap` access point automatically so you can re-provision it from a browser without +opening up the robot. This behavior is controlled by the **Fallback AP on disconnect** toggle +in the WiFi section , it's on by default. If you turn it off, recovery from a broken WiFi +config requires the serial menu. + +To wipe credentials entirely and force the device back into first-time setup mode (always-on +AP, no auto-reconnect), use **Settings -> WiFi -> Forget current network**. The device will +broadcast `-ap` until you provision a new network. + --- ## Troubleshooting @@ -328,6 +472,11 @@ Go to **Settings -> Diagnostics** and set **Log Level**: - **Debug** — everything in Info plus all serial commands with raw responses. Auto-reverts to off after 10 minutes. +The Diagnostics section also shows a **Battery diagnostics** card with live readings from the +robot: charge percentage, voltage, current, temperature, cycle count, and chemistry details. +If you replace the battery, use the **New Battery** button to reset the fuel gauge calibration +(only after physically installing the new pack). + For long-running diagnostics (e.g. catching an intermittent error that only appears after hours of idling), enable **Remote Syslog** in the same section. This sends all log output over UDP (port 514) to a syslog receiver on your network instead of writing to flash. The @@ -355,6 +504,41 @@ To download all logs: use the individual download buttons for each file you need reporting an issue, include at least the `current.jsonl` and any archived log files from the time the problem occurred. +### Robot Stuck Starting a House Clean + +Some Botvac D7 robots can get stuck while starting a house clean with a robot notice like +"Failed to load persistent map". Debug logs usually show `UI_ALERT_PM_LOAD_FAIL` (`234`) and +the robot remains in `UIMGR_STATE_STARTHOUSECLEANING` while the robot state stays +`ST_C_Standby`. This appears to be a robot firmware-side state rather than an OpenNeato +scheduler failure. See [issue #24](https://github.com/renjfk/OpenNeato/issues/24) for the +original field reports that led to documenting this recovery path. + +Try the recovery steps in this order: + +1. Open **Settings -> Diagnostics** and use **Clear Robot Errors**, then try starting a clean + again. +2. If it remains stuck, open **Settings -> Robot** and use **Restart Robot**. This is equivalent + to a robot power cycle and has been the most reliable software recovery in reports so far. +3. If the issue keeps returning, enable **Debug** logging or **Remote Syslog** before the next + scheduled run and save the logs when it happens. +4. As an advanced hardware recovery step, fully remove power from the robot by disconnecting the + battery for a few minutes, then reconnect it. Some users reported the issue stopped after + maintenance that included a battery disconnect, cleaning sensors, clearing robot logs, and + removing debris from the brush or motor area. + +> [!CAUTION] +> Disconnecting the robot battery requires opening the robot. Only do this if you are comfortable +> with hardware disassembly, and avoid pulling on wires or shorting the battery connector. + +When reporting this issue, include the robot software version from: + +```bash +curl -X POST 'http://neato.local/api/serial?cmd=GetVersion' +``` + +In known reports, affected robots were running Neato software `4.5.3.189`, but this is not yet a +confirmed root cause. + ### Downloading Cleaning Maps If a cleaning session produced unexpected results (missed areas, strange paths, early @@ -362,7 +546,36 @@ termination), download the cleaning history session from **History**. Each sessi the robot's recorded path rendered as a coverage map, along with stats like duration, distance, area covered, and battery usage. - +### Recovering Corrupted Cleaning History + +If the History page shows a "Cleaning history is corrupted" message, one of the stored sessions +has malformed data and is preventing the list from loading. This can happen if a cleaning was +interrupted by a power loss or unexpected reset. The following steps let you find and remove +the bad session(s) without losing the rest. Replace `YOUR_ROBOT` with your bridge's hostname +or IP address. + +1. **Fetch the session list** and inspect it visually: + + ```sh + curl "http://YOUR_ROBOT/api/history" + ``` + + Paste the response into a JSON validator (for example + [jsonlint.com](https://jsonlint.com) or [jsonformatter.org](https://jsonformatter.org)). + The validator will flag the position of the malformed entry. Note the `name` field of the + bad session (e.g. `1776667071.jsonl.hs`). + +2. **Delete the bad session**: + + ```sh + curl -X DELETE "http://YOUR_ROBOT/api/history/" + ``` + +3. **Reload the History page**. If multiple sessions are corrupted, repeat steps 1-2 until the + list loads. + +If you'd rather not investigate, the History page also offers a **Delete all history** button +that wipes every session in one go and restores the list view. ### Factory Reset @@ -481,9 +694,9 @@ A few typical maintenance tasks: ```bash # New battery installed (resets fuel gauge calibration, requires TestMode) -curl -X POST 'http://neato.local/api/testmode?enable=1' +curl -X POST 'http://neato.local/api/serial?cmd=TestMode%20On' curl -X POST 'http://neato.local/api/serial?cmd=NewBattery' -curl -X POST 'http://neato.local/api/testmode?enable=0' +curl -X POST 'http://neato.local/api/serial?cmd=TestMode%20Off' # Reset robot user settings to factory defaults curl -X POST 'http://neato.local/api/serial?cmd=SetUserSettings%20Reset' diff --git a/firmware/src/cleaning_history.cpp b/firmware/src/cleaning_history.cpp index b68e7ed..25477a3 100644 --- a/firmware/src/cleaning_history.cpp +++ b/firmware/src/cleaning_history.cpp @@ -1,1299 +1,1299 @@ -#include "cleaning_history.h" -#include "json_fields.h" -#include "neato_serial.h" -#include "system_manager.h" -#include -#include - -// Heatshrink decompression can drop a byte that merges two JSONL lines into -// one, so substring matching isn't enough — validate the braces balance and -// nothing trails the top-level object before embedding into /api/history. -static bool isValidMetaLine(const String& line, const char* expectedTypePrefix) { - if (line.length() < 2 || line[0] != '{' || line[line.length() - 1] != '}') - return false; - if (line.indexOf(expectedTypePrefix) < 0) - return false; - int depth = 0; - bool inString = false; - bool escape = false; - for (size_t i = 0; i < line.length(); i++) { - char c = line[i]; - if (escape) { - escape = false; - continue; - } - if (inString) { - if (c == '\\') - escape = true; - else if (c == '"') - inString = false; - continue; - } - if (c == '"') - inString = true; - else if (c == '{') - depth++; - else if (c == '}') { - depth--; - if (depth < 0) - return false; - // Reject trailing content after the top-level object closes - if (depth == 0 && i != line.length() - 1) - return false; - } - } - return depth == 0 && !inString; -} - -CleaningHistory::CleaningHistory(NeatoSerial& neato, DataLogger& logger, SystemManager& sysMgr) : - LoopTask(HISTORY_INTERVAL_IDLE_MS), neato(neato), dataLogger(logger), systemManager(sysMgr) { - TaskRegistry::add(this); -} - -void CleaningHistory::notifyCleanStart() { - if (collecting) - return; // Already recording - setInterval(HISTORY_INTERVAL_ACTIVE_MS); -} - -void CleaningHistory::tick() { - // Run incremental compression when a session just finished - if (compressing) { - if (compressStep()) { - // Compression done - remove raw source and cache metadata - compressSrc.close(); - compressDst.close(); - SPIFFS.remove(compressSrcPath); - LOG("HIST", "Compression done: %s", compressDstPath.c_str()); - - // Cache session/summary from memory (avoids decompressing on next list request) - String hsName = compressDstPath; - int lastSlash = hsName.lastIndexOf('/'); - if (lastSlash >= 0) - hsName = hsName.substring(lastSlash + 1); - metaCache[hsName] = {pendingSessionJson, pendingSummaryJson}; - pendingSessionJson = ""; - pendingSummaryJson = ""; - - compressing = false; - setInterval(HISTORY_INTERVAL_IDLE_MS); - } - return; - } - - if (fetchPending) - return; - - if (collecting) { - // Periodically flush buffered pose snapshots to disk - if (!writeBuffer.empty() && millis() - lastFlushMs >= HISTORY_FLUSH_INTERVAL_MS) { - flushWriteBuffer(); - } - collectSnapshot(); - } else { - checkState(); - enforceLimits(); - } -} - -// -- State watching (idle mode) ---------------------------------------------- - -void CleaningHistory::checkState() { - fetchPending = true; - neato.getState([this](bool ok, const RobotState& state) { - fetchPending = false; - if (!ok) - return; - - bool wasCleaning = isCleaningState(prevUiState); - bool nowCleaning = isCleaningState(state.uiState); - - // CLEANINGSUSPENDED + Charging_Cleaning means the robot is on the dock - // recharging mid-clean and will resume automatically — treat as active - bool isMidCleanRecharge = isSuspendedState(state.uiState) && state.robotState.indexOf("Charging_Cleaning") >= 0; - - // First poll after boot: if the robot is idle, finalize any orphan sessions - // left by a previous crash/reboot so they don't get merged into the next clean - if (prevUiState.isEmpty() && !nowCleaning && !isMidCleanRecharge && !recoveryAttempted) { - recoveryAttempted = true; - finalizeOrphanSessions(); - } - - if (!wasCleaning && (nowCleaning || isMidCleanRecharge) && !recoverCollection(state.uiState)) { - startCollection(state.uiState); - } - - prevUiState = state.uiState; - }); -} - -bool CleaningHistory::isCleaningState(const String& uiState) { - return uiState.indexOf("HOUSECLEANINGRUNNING") >= 0 || uiState.indexOf("HOUSECLEANINGPAUSED") >= 0 || - uiState.indexOf("SPOTCLEANINGRUNNING") >= 0 || uiState.indexOf("SPOTCLEANINGPAUSED") >= 0 || - uiState.indexOf("MANUALCLEANING") >= 0; -} - -bool CleaningHistory::isPausedState(const String& uiState) { - return uiState.indexOf("CLEANINGPAUSED") >= 0; -} - -bool CleaningHistory::isDockingState(const String& uiState) { - return uiState.indexOf("DOCKING") >= 0; -} - -bool CleaningHistory::isSuspendedState(const String& uiState) { - return uiState.indexOf("CLEANINGSUSPENDED") >= 0; -} - -String CleaningHistory::cleanModeFromState(const String& uiState) { - if (uiState.indexOf("HOUSECLEANING") >= 0) - return "house"; - if (uiState.indexOf("SPOTCLEANING") >= 0) - return "spot"; - if (uiState.indexOf("MANUALCLEANING") >= 0) - return "manual"; - return "unknown"; -} - -void CleaningHistory::resetSession() { - snapshotCount = 0; - rechargeCount = 0; - totalDistance = 0.0f; - totalRotation = 0.0f; - maxDistFromOrigin = 0.0f; - errorsDuringClean = 0; - prevHadError = false; - hasPrevPose = false; - prevX = 0.0f; - prevY = 0.0f; - prevTheta = 0.0f; - originX = 0.0f; - originY = 0.0f; - visitedCells.clear(); - cleanMode = ""; - sessionStartTime = 0; - batteryStart = -1; - recharging = false; - pendingSessionJson = ""; - pendingSummaryJson = ""; -} - -void CleaningHistory::startCollection(const String& uiState) { - collecting = true; - resetSession(); - - cleanMode = cleanModeFromState(uiState); - sessionStartTime = systemManager.now(); - - // Create session file: /history/.jsonl - activeFilePath = String(HISTORY_DIR) + "/" + String(static_cast(sessionStartTime)) + ".jsonl"; - activeFile = SPIFFS.open(activeFilePath, FILE_WRITE); - if (!activeFile) { - LOG("HIST", "Failed to create session file: %s", activeFilePath.c_str()); - collecting = false; - return; - } - - // Fetch battery level for session metadata, then write header - neato.getCharger([this](bool ok, const ChargerData& charger) { - if (ok) { - batteryStart = charger.fuelPercent; - } - writeSessionHeader(); - }); - - setInterval(HISTORY_INTERVAL_ACTIVE_MS); - LOG("HIST", "Collection started (mode: %s, file: %s)", cleanMode.c_str(), activeFilePath.c_str()); - dataLogger.logGenericEvent("history_start", {{"mode", cleanMode, FIELD_STRING}}); -} - -void CleaningHistory::stopCollection() { - // Flush any buffered snapshots before writing summary - flushWriteBuffer(); - - // Discard sessions that are too short to produce a useful map - if (snapshotCount < HISTORY_MIN_SNAPSHOTS) { - if (activeFile) { - activeFile.close(); - } - SPIFFS.remove(activeFilePath); - - LOG("HIST", "Session discarded (%u snapshots < %d minimum)", snapshotCount, HISTORY_MIN_SNAPSHOTS); - dataLogger.logGenericEvent("history_discard", {{"snapshots", String(snapshotCount), FIELD_INT}}); - - // Mark stats invalid so NotificationManager doesn't enrich a "done" - // notification with stale data from the previous session. - lastCleanStats.valid = false; - lastCleanStats.sessionId = ++sessionCounter; - - collecting = false; - recharging = false; - setInterval(HISTORY_INTERVAL_IDLE_MS); - return; - } - - // Fetch final battery level for summary - neato.getCharger([this](bool ok, const ChargerData& charger) { - int batteryEnd = ok ? charger.fuelPercent : -1; - - writeSessionSummary(batteryEnd); - - // Close the raw file - if (activeFile) { - activeFile.close(); - } - - collecting = false; - recharging = false; - setInterval(HISTORY_INTERVAL_IDLE_MS); - - float areaCovered = static_cast(visitedCells.size()) * HISTORY_AREA_CELL_M * HISTORY_AREA_CELL_M; - - // Snapshot stats for notification enrichment (survives resetSession). - // sessionId increments last so NotificationManager can detect that - // the async charger fetch above has finalized the stats. - time_t endTime = systemManager.now(); - lastCleanStats.valid = true; - lastCleanStats.mode = cleanMode; - lastCleanStats.durationSec = (sessionStartTime > 0 && endTime > sessionStartTime) - ? static_cast(endTime - sessionStartTime) - : 0; - lastCleanStats.areaCoveredM2 = areaCovered; - lastCleanStats.distanceM = totalDistance; - lastCleanStats.batteryStart = batteryStart; - lastCleanStats.batteryEnd = batteryEnd; - lastCleanStats.recharges = rechargeCount; - lastCleanStats.sessionId = ++sessionCounter; - - LOG("HIST", "Collection stopped (%u snapshots, %.1fm², %d recharges)", snapshotCount, areaCovered, - rechargeCount); - dataLogger.logGenericEvent("history_stop", {{"snapshots", String(snapshotCount), FIELD_INT}, - {"area_m2", String(areaCovered, 1), FIELD_FLOAT}, - {"recharges", String(rechargeCount), FIELD_INT}, - {"battery_end", String(batteryEnd), FIELD_INT}}); - - // Start non-blocking compression: raw .jsonl -> .jsonl.hs - compressSrcPath = activeFilePath; - compressDstPath = activeFilePath + ".hs"; - compressSrc = SPIFFS.open(compressSrcPath, FILE_READ); - compressDst = SPIFFS.open(compressDstPath, FILE_WRITE); - if (compressSrc && compressDst) { - heatshrink_encoder_reset(&compressEncoder); - compressInputDone = false; - compressing = true; - setInterval(HISTORY_COMPRESS_INTERVAL_MS); - LOG("HIST", "Starting compression: %s -> %s", compressSrcPath.c_str(), compressDstPath.c_str()); - } else { - // Compression failed - keep raw file - if (compressSrc) - compressSrc.close(); - if (compressDst) - compressDst.close(); - LOG("HIST", "Compression setup failed, keeping raw file"); - } - }); -} - -// -- Incremental compression (called from tick) ------------------------------ - -bool CleaningHistory::compressStep() { - static const size_t CHUNK_SIZE = 512; - uint8_t inBuf[CHUNK_SIZE]; - uint8_t outBuf[CHUNK_SIZE]; - - if (!compressInputDone) { - int bytesRead = compressSrc.read(inBuf, CHUNK_SIZE); - if (bytesRead <= 0) { - compressInputDone = true; - } else { - size_t offset = 0; - while (offset < static_cast(bytesRead)) { - size_t sunk = 0; - HSE_sink_res sres = - heatshrink_encoder_sink(&compressEncoder, inBuf + offset, bytesRead - offset, &sunk); - if (sres < 0) { - LOG("HIST", "Heatshrink sink error"); - compressSrc.close(); - compressDst.close(); - SPIFFS.remove(compressDstPath); - compressing = false; - return true; - } - offset += sunk; - - size_t outSz = 0; - HSE_poll_res pres; - do { - pres = heatshrink_encoder_poll(&compressEncoder, outBuf, CHUNK_SIZE, &outSz); - if (pres < 0) { - LOG("HIST", "Heatshrink poll error"); - compressSrc.close(); - compressDst.close(); - SPIFFS.remove(compressDstPath); - compressing = false; - return true; - } - if (outSz > 0) { - compressDst.write(outBuf, outSz); - } - } while (pres == HSER_POLL_MORE); - } - } - return false; - } - - // Input exhausted — finish encoding - HSE_finish_res fres = heatshrink_encoder_finish(&compressEncoder); - if (fres < 0) { - LOG("HIST", "Heatshrink finish error"); - compressSrc.close(); - compressDst.close(); - SPIFFS.remove(compressDstPath); - compressing = false; - return true; - } - - size_t outSz = 0; - HSE_poll_res pres; - do { - pres = heatshrink_encoder_poll(&compressEncoder, outBuf, CHUNK_SIZE, &outSz); - if (pres < 0) { - LOG("HIST", "Heatshrink poll error during finish"); - compressSrc.close(); - compressDst.close(); - SPIFFS.remove(compressDstPath); - compressing = false; - return true; - } - if (outSz > 0) { - compressDst.write(outBuf, outSz); - } - } while (pres == HSER_POLL_MORE); - - return (fres == HSER_FINISH_DONE); -} - -// -- Session header/summary -------------------------------------------------- - -void CleaningHistory::writeSessionHeader() { - std::vector fields = {{"type", "session", FIELD_STRING}, {"mode", cleanMode, FIELD_STRING}}; - if (sessionStartTime > 0) - fields.push_back({"time", String(static_cast(sessionStartTime)), FIELD_INT}); - if (batteryStart >= 0) - fields.push_back({"battery", String(batteryStart), FIELD_INT}); - String json = fieldsToJson(fields); - pendingSessionJson = json; - writeLine(json); -} - -void CleaningHistory::writeSessionSummary(int batteryEnd) { - time_t endTime = systemManager.now(); - long duration = - (sessionStartTime > 0 && endTime > sessionStartTime) ? static_cast(endTime - sessionStartTime) : 0; - float areaCovered = static_cast(visitedCells.size()) * HISTORY_AREA_CELL_M * HISTORY_AREA_CELL_M; - std::vector fields = {{"type", "summary", FIELD_STRING}}; - if (endTime > 0) - fields.push_back({"time", String(static_cast(endTime)), FIELD_INT}); - fields.push_back({"duration", String(duration), FIELD_INT}); - fields.push_back({"mode", cleanMode, FIELD_STRING}); - fields.push_back({"recharges", String(rechargeCount), FIELD_INT}); - fields.push_back({"snapshots", String(static_cast(snapshotCount)), FIELD_INT}); - fields.push_back({"distanceTraveled", String(totalDistance, 2), FIELD_FLOAT}); - fields.push_back({"maxDistFromOrigin", String(maxDistFromOrigin, 2), FIELD_FLOAT}); - fields.push_back({"totalRotation", String(totalRotation, 1), FIELD_FLOAT}); - fields.push_back({"areaCovered", String(areaCovered, 2), FIELD_FLOAT}); - fields.push_back({"errorsDuringClean", String(errorsDuringClean), FIELD_INT}); - if (batteryStart >= 0) - fields.push_back({"batteryStart", String(batteryStart), FIELD_INT}); - if (batteryEnd >= 0) - fields.push_back({"batteryEnd", String(batteryEnd), FIELD_INT}); - String json = fieldsToJson(fields); - pendingSummaryJson = json; - writeLine(json); -} - -// -- Buffered file writing --------------------------------------------------- -// Pose snapshots accumulate in writeBuffer; flushed every HISTORY_FLUSH_INTERVAL_MS -// or when the session ends (stopCollection / recovery). This reduces flash wear -// from one write every 2s to one write every 30s during a cleaning session. -// Session header, summary, and recharge markers flush immediately since they -// are rare one-shot writes where crash safety matters. - -void CleaningHistory::writeLine(const String& line) { - if (!activeFile) - return; - activeFile.println(line); - activeFile.flush(); -} - -void CleaningHistory::bufferLine(const String& line) { - if (!activeFile) - return; - writeBuffer.push_back(line); -} - -void CleaningHistory::flushWriteBuffer() { - if (writeBuffer.empty() || !activeFile) - return; - // Build a single string and write once to minimize SPIFFS COW metadata updates - String batch; - size_t total = 0; - for (const auto& line: writeBuffer) { - total += line.length() + 1; - } - batch.reserve(total); - for (const auto& line: writeBuffer) { - batch += line; - batch += '\n'; - } - activeFile.write(reinterpret_cast(batch.c_str()), batch.length()); - activeFile.flush(); - writeBuffer.clear(); - lastFlushMs = millis(); -} - -// -- Snapshot collection (active mode) --------------------------------------- - -static bool parsePose(const String& raw, float& x, float& y, float& theta, float& time) { - int xPos = raw.indexOf("X="); - int yPos = raw.indexOf("Y="); - int tPos = raw.indexOf("Theta="); - int tmPos = raw.indexOf("Time="); - if (xPos < 0 || yPos < 0 || tPos < 0 || tmPos < 0) - return false; - - x = raw.substring(xPos + 2).toFloat(); - y = raw.substring(yPos + 2).toFloat(); - theta = raw.substring(tPos + 6).toFloat(); - time = raw.substring(tmPos + 5).toFloat(); - return true; -} - -// Parse a single JSONL line and update session accumulators (header, recharge, pose). -// Returns true if the line was a session header. -bool CleaningHistory::replayLine(const String& line) { - auto fields = fieldsFromJson(line); - if (fields.empty()) - return false; - - const Field *typeField = findField(fields, "type"); - if (typeField && typeField->value == "session") { - const Field *modeField = findField(fields, "mode"); - if (modeField && !modeField->value.isEmpty()) - cleanMode = modeField->value; - - const Field *timeField = findField(fields, "time"); - if (timeField) { - long t = timeField->value.toInt(); - if (t > 0) - sessionStartTime = static_cast(t); - } - - const Field *battField = findField(fields, "battery"); - if (battField) - batteryStart = battField->value.toInt(); - return true; - } - - if (typeField && typeField->value == "recharge") { - rechargeCount++; - return false; - } - - // Pose snapshot line - const Field *xf = findField(fields, "x"); - const Field *yf = findField(fields, "y"); - const Field *tf = findField(fields, "t"); - if (xf && yf && tf) { - updateAccumulators(xf->value.toFloat(), yf->value.toFloat(), tf->value.toFloat()); - snapshotCount++; - } - return false; -} - -bool CleaningHistory::recoverCollection(const String& uiState) { - // Only attempt recovery once after boot — avoid scanning filesystem on every clean start - if (recoveryAttempted) - return false; - recoveryAttempted = true; - - File root = SPIFFS.open(HISTORY_DIR); - if (!root || !root.isDirectory()) - return false; - - // Collect all history filenames (both .jsonl and .jsonl.hs) and sort descending - // (newest first). Then walk from newest to oldest: collect orphan .jsonl files - // until we hit a compressed .jsonl.hs — that marks a completed session boundary, - // so everything older belongs to previous cleans and should not be touched. - // Also clean up any raw .jsonl that has a .jsonl.hs counterpart (stale leftover). - std::vector allFiles; - File entry = root.openNextFile(); - while (entry) { - String path = String(entry.path()); - if (path.endsWith(".jsonl") || path.endsWith(".jsonl.hs")) - allFiles.push_back(path); - entry = root.openNextFile(); - } - - std::sort(allFiles.begin(), allFiles.end(), [](const String& a, const String& b) { return a > b; }); - - std::vector orphans; - for (const auto& path: allFiles) { - if (path.endsWith(".jsonl.hs")) { - // Hit a compressed file — this is the boundary of a completed session. - // Stop collecting; everything older is from previous cleans. - break; - } - // Raw .jsonl — check if a compressed copy exists (stale leftover) - if (SPIFFS.exists(path + ".hs")) { - SPIFFS.remove(path); - LOG("HIST", "Removed stale raw file: %s (compressed copy exists)", path.c_str()); - continue; - } - // Check if it has a summary (completed but not yet compressed) - String firstLine, lastLine; - readFirstLastLines(path, false, firstLine, lastLine); - if (lastLine.indexOf("\"type\":\"summary\"") >= 0) - continue; - orphans.push_back(path); - } - - if (orphans.empty()) - return false; - - // orphans are newest-first; reverse to get chronological order (oldest first) - std::reverse(orphans.begin(), orphans.end()); - - // The oldest orphan becomes the target; newer ones are merged into it then deleted - String targetPath = orphans[0]; - - if (orphans.size() > 1) { - File target = SPIFFS.open(targetPath, FILE_APPEND); - if (target) { - for (size_t i = 1; i < orphans.size(); i++) { - File src = SPIFFS.open(orphans[i], FILE_READ); - if (!src) - continue; - while (src.available()) { - String line = src.readStringUntil('\n'); - line.trim(); - if (line.isEmpty()) - continue; - // Skip duplicate session headers from newer orphans - if (line.indexOf("\"type\":\"session\"") >= 0) - continue; - target.println(line); - } - src.close(); - SPIFFS.remove(orphans[i]); - LOG("HIST", "Merged orphan %s into %s", orphans[i].c_str(), targetPath.c_str()); - } - target.flush(); - target.close(); - } - } - - // Now replay the merged file to rebuild accumulators - resetSession(); - cleanMode = cleanModeFromState(uiState); - activeFilePath = targetPath; - - File recoveryFile = SPIFFS.open(targetPath, FILE_READ); - if (!recoveryFile) - return false; - - bool hasSessionHeader = false; - while (recoveryFile.available()) { - String line = recoveryFile.readStringUntil('\n'); - line.trim(); - if (line.isEmpty()) - continue; - if (replayLine(line)) - hasSessionHeader = true; - } - recoveryFile.close(); - - // Fall back to extracting start time from the filename - if (sessionStartTime <= 0) { - String name = targetPath; - int slash = name.lastIndexOf('/'); - if (slash >= 0) - name = name.substring(slash + 1); - int dot = name.indexOf('.'); - if (dot > 0) - sessionStartTime = static_cast(name.substring(0, dot).toInt()); - } - - activeFile = SPIFFS.open(activeFilePath, FILE_APPEND); - if (!activeFile) { - activeFilePath = ""; - resetSession(); - return false; - } - - collecting = true; - setInterval(HISTORY_INTERVAL_ACTIVE_MS); - LOG("HIST", "Recovered session: %s (%u snapshots, %zu orphans merged)", activeFilePath.c_str(), snapshotCount, - orphans.size()); - dataLogger.logGenericEvent("history_recover", {{"path", activeFilePath, FIELD_STRING}, - {"snapshots", String(snapshotCount), FIELD_INT}, - {"orphans", String(static_cast(orphans.size())), FIELD_INT}}); - - if (!hasSessionHeader) { - writeSessionHeader(); - } - return true; -} - -void CleaningHistory::finalizeOrphanSessions() { - // Called once at boot when the robot is idle. Finds orphan .jsonl files - // (no summary line, no compressed counterpart) and finalizes them: replay - // to compute summary stats, append summary line, then start compression. - File root = SPIFFS.open(HISTORY_DIR); - if (!root || !root.isDirectory()) - return; - - std::vector allFiles; - File entry = root.openNextFile(); - while (entry) { - String path = String(entry.path()); - if (path.endsWith(".jsonl") || path.endsWith(".jsonl.hs")) - allFiles.push_back(path); - entry = root.openNextFile(); - } - - std::sort(allFiles.begin(), allFiles.end(), [](const String& a, const String& b) { return a > b; }); - - std::vector orphans; - for (const auto& path: allFiles) { - if (path.endsWith(".jsonl.hs")) - break; // Completed session boundary - if (SPIFFS.exists(path + ".hs")) { - SPIFFS.remove(path); - continue; - } - String firstLine, lastLine; - readFirstLastLines(path, false, firstLine, lastLine); - if (lastLine.indexOf("\"type\":\"summary\"") >= 0) - continue; - orphans.push_back(path); - } - - if (orphans.empty()) - return; - - // Finalize each orphan: replay to rebuild stats, write summary, compress - for (const auto& orphanPath: orphans) { - resetSession(); - - // Replay to rebuild accumulators - File f = SPIFFS.open(orphanPath, FILE_READ); - if (!f) - continue; - while (f.available()) { - String line = f.readStringUntil('\n'); - line.trim(); - if (line.isEmpty()) - continue; - replayLine(line); - } - f.close(); - - // Extract start time from filename if not found in header - if (sessionStartTime <= 0) { - String name = orphanPath; - int slash = name.lastIndexOf('/'); - if (slash >= 0) - name = name.substring(slash + 1); - int dot = name.indexOf('.'); - if (dot > 0) - sessionStartTime = static_cast(name.substring(0, dot).toInt()); - } - - // Open for append and write summary - activeFilePath = orphanPath; - activeFile = SPIFFS.open(orphanPath, FILE_APPEND); - if (!activeFile) - continue; - - writeSessionSummary(-1); // Battery end unknown for interrupted sessions - activeFile.close(); - - LOG("HIST", "Finalized orphan: %s (%u snapshots)", orphanPath.c_str(), snapshotCount); - dataLogger.logGenericEvent("history_finalize", {{"path", orphanPath, FIELD_STRING}, - {"snapshots", String(snapshotCount), FIELD_INT}}); - - // Queue compression (only one at a time — first orphan wins, rest will - // be picked up on next boot or left as uncompressed) - if (!compressing) { - compressSrcPath = orphanPath; - compressDstPath = orphanPath + ".hs"; - compressSrc = SPIFFS.open(compressSrcPath, FILE_READ); - compressDst = SPIFFS.open(compressDstPath, FILE_WRITE); - if (compressSrc && compressDst) { - heatshrink_encoder_reset(&compressEncoder); - compressInputDone = false; - compressing = true; - setInterval(HISTORY_COMPRESS_INTERVAL_MS); - } else { - if (compressSrc) - compressSrc.close(); - if (compressDst) - compressDst.close(); - } - } - } - - activeFilePath = ""; - activeFile = File(); -} - -void CleaningHistory::collectSnapshot() { - fetchPending = true; - - neato.getState([this](bool stateOk, const RobotState& state) { - if (stateOk) { - prevUiState = state.uiState; - - bool isDocking = isDockingState(state.uiState); - bool isCleaning = isCleaningState(state.uiState); - bool isSuspended = isSuspendedState(state.uiState); - bool isChargingMidClean = state.robotState.indexOf("Charging_Cleaning") >= 0; - - // Mid-clean recharge: robot returns to base to charge before resuming. - // The UI state can be DOCKING (on the way back) or CLEANINGSUSPENDED - // (already on the dock and charging). Both combined with the - // Charging_Cleaning robot state indicate a recharge-and-resume cycle. - if ((isDocking || isSuspended) && isChargingMidClean) { - if (!recharging) { - recharging = true; - rechargeCount++; - LOG("HIST", "Recharge #%d detected — pausing collection", rechargeCount); - dataLogger.logGenericEvent("history_recharge_start", {{"count", String(rechargeCount), FIELD_INT}}); - - if (hasPrevPose) { - writeLine(fieldsToJson({{"type", "recharge", FIELD_STRING}, - {"x", String(prevX, 3), FIELD_FLOAT}, - {"y", String(prevY, 3), FIELD_FLOAT}})); - } - } - fetchPending = false; - return; - } - - if (recharging && isCleaning) { - recharging = false; - LOG("HIST", "Recharge done — resuming collection"); - dataLogger.logGenericEvent("history_recharge_end", {}); - } - - if (!isCleaning && !isDocking && !isSuspended) { - fetchPending = false; - stopCollection(); - return; - } - - // Paused — keep session open but skip snapshot collection - if (isPausedState(state.uiState)) { - fetchPending = false; - return; - } - } - - neato.getErr([this](bool errOk, const ErrorData& err) { - if (errOk) { - if (err.hasError && !prevHadError) { - errorsDuringClean++; - } - prevHadError = err.hasError; - } - - if (recharging) { - fetchPending = false; - return; - } - - neato.getRobotPos(true, [this](bool posOk, const RobotPosData& pos) { - fetchPending = false; - if (!posOk) - return; - - float x, y, theta, time; - if (!parsePose(pos.raw, x, y, theta, time)) { - LOG("HIST", "Failed to parse pose"); - return; - } - - writeSnapshot(x, y, theta, time); - }); - }); - }); -} - -void CleaningHistory::updateAccumulators(float x, float y, float theta) { - if (!hasPrevPose) { - originX = x; - originY = y; - prevX = x; - prevY = y; - prevTheta = theta; - hasPrevPose = true; - } else { - float dx = x - prevX; - float dy = y - prevY; - totalDistance += sqrtf(dx * dx + dy * dy); - - float dTheta = theta - prevTheta; - if (dTheta > 180.0f) - dTheta -= 360.0f; - if (dTheta < -180.0f) - dTheta += 360.0f; - totalRotation += fabsf(dTheta); - - prevX = x; - prevY = y; - prevTheta = theta; - } - - float dox = x - originX; - float doy = y - originY; - float distFromOrigin = sqrtf(dox * dox + doy * doy); - if (distFromOrigin > maxDistFromOrigin) { - maxDistFromOrigin = distFromOrigin; - } - - int ix = static_cast(floorf(x / HISTORY_AREA_CELL_M)); - int iy = static_cast(floorf(y / HISTORY_AREA_CELL_M)); - uint32_t cellKey = (static_cast(ix & 0xFFFF) << 16) | static_cast(iy & 0xFFFF); - visitedCells.insert(cellKey); -} - -void CleaningHistory::writeSnapshot(float x, float y, float theta, float time) { - // Localization resets to origin right before session ends — drop if the - // robot was far from origin (genuine return-to-base passes through gradually) - if (hasPrevPose && fabsf(x) < 0.001f && fabsf(y) < 0.001f && fabsf(theta) < 0.1f) { - float prevDist = sqrtf(prevX * prevX + prevY * prevY); - if (prevDist > 1.0f) { - return; - } - } - - String line = "{\"x\":" + String(x, 3) + ",\"y\":" + String(y, 3) + ",\"t\":" + String(theta, 1) + - ",\"ts\":" + String(time, 1) + "}"; - - updateAccumulators(x, y, theta); - bufferLine(line); - snapshotCount++; - - if (snapshotCount % 10 == 0) { - LOG("HIST", "Snapshot #%u (%.1fm traveled, pose: %.2f,%.2f,%.0f)", snapshotCount, totalDistance, x, y, theta); - } -} - -// -- Storage enforcement (mirrors DataLogger::enforceLimits) ----------------- - -void CleaningHistory::enforceLimits() { - // Count session files, sum directory size, and find the oldest in one pass - int fileCount = 0; - size_t histDirBytes = 0; - String oldest; - File root = SPIFFS.open(HISTORY_DIR); - if (!root || !root.isDirectory()) - return; - - File entry = root.openNextFile(); - while (entry) { - String name = String(entry.name()); - histDirBytes += entry.size(); - if (name.endsWith(".jsonl") || name.endsWith(".jsonl.hs")) { - fileCount++; - if (oldest.isEmpty() || name < oldest) { - oldest = name; - } - } - entry = root.openNextFile(); - } - - if (oldest.isEmpty()) - return; - - // History budget: total filesystem cap minus non-history data, floored at minimum reserve - size_t total = SPIFFS.totalBytes(); - size_t globalCap = (total * HISTORY_MAX_FS_PERCENT) / 100; - size_t nonHistBytes = SPIFFS.usedBytes() > histDirBytes ? SPIFFS.usedBytes() - histDirBytes : 0; - size_t available = globalCap > nonHistBytes ? globalCap - nonHistBytes : 0; - size_t minReserved = (total * HISTORY_MIN_FS_PERCENT) / 100; - size_t histBudget = available > minReserved ? available : minReserved; - - if (histDirBytes > histBudget || fileCount > HISTORY_MAX_FILES) { - String fullPath = String(HISTORY_DIR) + "/" + oldest; - LOG("HIST", "Limit: deleting %s (files=%d, histBytes=%u/%u)", fullPath.c_str(), fileCount, histDirBytes, - histBudget); - SPIFFS.remove(fullPath); - metaCache.erase(oldest); - } -} - -// -- File management (for API) ----------------------------------------------- - -void CleaningHistory::readFirstLastLines(const String& path, bool compressed, String& firstLine, String& lastLine) { - firstLine = ""; - lastLine = ""; - - if (compressed) { - // Stream-decompress keeping only the first and last lines in memory - // to avoid buffering the entire file (large sessions can exceed 100KB - // decompressed which exhausts ESP32-C3 heap). - File f = SPIFFS.open(path, FILE_READ); - if (!f) - return; - CompressedLogReader reader(std::move(f)); - uint8_t buf[256]; - size_t n; - bool firstDone = false; - // Current incomplete line being assembled from chunks - String current; - while ((n = reader.read(buf, sizeof(buf))) > 0) { - for (size_t i = 0; i < n; i++) { - char c = static_cast(buf[i]); - if (c == '\n') { - current.trim(); - if (current.length() > 0) { - if (!firstDone) { - firstLine = current; - firstDone = true; - } - // Always overwrite lastLine with the most recent non-empty line - lastLine = current; - } - current = ""; - } else { - current += c; - } - } - } - // Handle final chunk without trailing newline - current.trim(); - if (current.length() > 0) { - if (!firstDone) { - firstLine = current; - } - lastLine = current; - } - } else { - // Plain .jsonl — read first line directly, seek backward for last line - File f = SPIFFS.open(path, FILE_READ); - if (!f) - return; - // Read first line - firstLine = f.readStringUntil('\n'); - firstLine.trim(); - // Seek backward from end to find last line - size_t fileSize = f.size(); - if (fileSize < 2) { - f.close(); - return; - } - int pos = static_cast(fileSize) - 2; // Skip trailing newline - while (pos >= 0) { - f.seek(pos); - char c = static_cast(f.read()); - if (c == '\n') { - break; - } - pos--; - } - // pos is at the newline before last line, or -1 if only one line - if (pos < 0) { - f.close(); - return; // Only one line - } - f.seek(pos + 1); - lastLine = f.readStringUntil('\n'); - lastLine.trim(); - f.close(); - } -} - -std::vector CleaningHistory::listSessions() { - std::vector result; - - File root = SPIFFS.open(HISTORY_DIR); - if (!root || !root.isDirectory()) - return result; - - File entry = root.openNextFile(); - while (entry) { - String fullPath = String(entry.path()); - String name = fullPath; - int lastSlash = name.lastIndexOf('/'); - if (lastSlash >= 0) - name = name.substring(lastSlash + 1); - - if (name.endsWith(".jsonl") || name.endsWith(".jsonl.hs")) { - // During compression both raw .jsonl and partial .jsonl.hs exist — - // skip both until compression finishes and the raw source is deleted - if (compressing && (fullPath == compressSrcPath || fullPath == compressDstPath)) { - entry = root.openNextFile(); - continue; - } - - // Skip the actively collecting file during disk enumeration. - // We will manually append it at the end to avoid thread-safety / - // concurrent modification issues with filesystem iterators. - if (collecting && fullPath == activeFilePath) { - entry = root.openNextFile(); - continue; - } - - HistorySessionInfo info; - info.name = name; - info.size = entry.size(); - info.compressed = name.endsWith(".hs"); - - // Use cached metadata if available (avoids decompressing .hs files) - auto cached = metaCache.find(name); - if (cached != metaCache.end()) { - info.session = cached->second.session; - info.summary = cached->second.summary; - } else { - String firstLine, lastLine; - readFirstLastLines(fullPath, info.compressed, firstLine, lastLine); - if (isValidMetaLine(firstLine, "\"type\":\"session\"")) { - info.session = firstLine; - } - if (isValidMetaLine(lastLine, "\"type\":\"summary\"")) { - info.summary = lastLine; - } - // Cache for subsequent requests - metaCache[name] = {info.session, info.summary}; - } - - result.push_back(info); - } - entry = root.openNextFile(); - } - - // Always append the active session from memory to ensure exactly one entry - if (collecting && !activeFilePath.isEmpty()) { - String name = activeFilePath; - int lastSlash = name.lastIndexOf('/'); - if (lastSlash >= 0) - name = name.substring(lastSlash + 1); - - HistorySessionInfo info; - info.name = name; - info.size = activeFile ? activeFile.size() : 0; - info.compressed = false; - info.recording = true; - - String sessionJson = "{\"type\":\"session\",\"mode\":\"" + cleanMode + "\""; - if (sessionStartTime > 0) - sessionJson += ",\"time\":" + String(static_cast(sessionStartTime)); - if (batteryStart >= 0) - sessionJson += ",\"battery\":" + String(batteryStart); - sessionJson += "}"; - info.session = sessionJson; - // No summary for active session - - result.push_back(info); - } - - return result; -} - -std::shared_ptr CleaningHistory::readSession(const String& filename) { - String path = String(HISTORY_DIR) + "/" + filename; - - // Refuse to serve files involved in compression (partial .hs is corrupt) - if (compressing && (path == compressSrcPath || path == compressDstPath)) - return nullptr; - - if (!SPIFFS.exists(path)) - return nullptr; - - File f = SPIFFS.open(path, FILE_READ); - if (!f) - return nullptr; - - if (filename.endsWith(".hs")) { - return std::make_shared(std::move(f)); - } - return std::make_shared(std::move(f)); -} - -bool CleaningHistory::deleteSession(const String& filename) { - String path = String(HISTORY_DIR) + "/" + filename; - if (!SPIFFS.exists(path)) - return false; - metaCache.erase(filename); - return SPIFFS.remove(path); -} - -void CleaningHistory::deleteAllSessions() { - File root = SPIFFS.open(HISTORY_DIR); - if (!root || !root.isDirectory()) - return; - - std::vector paths; - File entry = root.openNextFile(); - while (entry) { - paths.push_back(String(entry.path())); - entry = root.openNextFile(); - } - - for (const auto& p: paths) { - SPIFFS.remove(p); - } - metaCache.clear(); - LOG("HIST", "Deleted %u session files", paths.size()); -} - -// -- Session import (compress-on-write from browser upload) ------------------- - -bool CleaningHistory::beginImport(const String& filename) { - importError = ""; - - if (importing) { - importError = "Import already in progress"; - return false; - } - if (collecting) { - importError = "Cannot import while recording"; - return false; - } - if (compressing) { - importError = "Cannot import while compressing"; - return false; - } - - // Validate filename pattern: .jsonl - if (!filename.endsWith(".jsonl")) { - importError = "Invalid filename"; - return false; - } - - // Check for duplicate (both raw and compressed) - String rawPath = String(HISTORY_DIR) + "/" + filename; - String hsPath = rawPath + ".hs"; - if (SPIFFS.exists(rawPath) || SPIFFS.exists(hsPath)) { - importError = "Session already exists"; - return false; - } - - // Open destination file for compressed output - importFilePath = hsPath; - importFile = SPIFFS.open(importFilePath, FILE_WRITE); - if (!importFile) { - importError = "Failed to create file"; - importFilePath = ""; - return false; - } - - heatshrink_encoder_reset(&importEncoder); - importBytesReceived = 0; - importing = true; - LOG("HIST", "Import started: %s", importFilePath.c_str()); - return true; -} - -bool CleaningHistory::writeImportChunk(const uint8_t *data, size_t len) { - if (!importing || !importFile) { - importError = "No import in progress"; - return false; - } - - importBytesReceived += len; - if (importBytesReceived > HISTORY_IMPORT_MAX_BYTES) { - importError = "File too large"; - importFile.close(); - SPIFFS.remove(importFilePath); - importing = false; - return false; - } - - static const size_t OUT_BUF_SIZE = 512; - uint8_t outBuf[OUT_BUF_SIZE]; - size_t offset = 0; - - while (offset < len) { - size_t sunk = 0; - HSE_sink_res sres = heatshrink_encoder_sink(&importEncoder, data + offset, len - offset, &sunk); - if (sres < 0) { - importError = "Compression error"; - importFile.close(); - SPIFFS.remove(importFilePath); - importing = false; - return false; - } - offset += sunk; - - // Drain compressed output - size_t outSz = 0; - HSE_poll_res pres; - do { - pres = heatshrink_encoder_poll(&importEncoder, outBuf, OUT_BUF_SIZE, &outSz); - if (pres < 0) { - importError = "Compression error"; - importFile.close(); - SPIFFS.remove(importFilePath); - importing = false; - return false; - } - if (outSz > 0) { - importFile.write(outBuf, outSz); - } - } while (pres == HSER_POLL_MORE); - } - return true; -} - -bool CleaningHistory::endImport() { - if (!importing || !importFile) { - importError = "No import in progress"; - return false; - } - - static const size_t OUT_BUF_SIZE = 512; - uint8_t outBuf[OUT_BUF_SIZE]; - - // Finish the encoder — may require multiple poll rounds - bool done = false; - while (!done) { - HSE_finish_res fres = heatshrink_encoder_finish(&importEncoder); - if (fres < 0) { - importError = "Compression finish error"; - importFile.close(); - SPIFFS.remove(importFilePath); - importing = false; - return false; - } - done = (fres == HSER_FINISH_DONE); - - size_t outSz = 0; - HSE_poll_res pres; - do { - pres = heatshrink_encoder_poll(&importEncoder, outBuf, OUT_BUF_SIZE, &outSz); - if (pres < 0) { - importError = "Compression finish error"; - importFile.close(); - SPIFFS.remove(importFilePath); - importing = false; - return false; - } - if (outSz > 0) { - importFile.write(outBuf, outSz); - } - } while (pres == HSER_POLL_MORE); - } - - importFile.close(); - importing = false; - LOG("HIST", "Import complete: %s", importFilePath.c_str()); - dataLogger.logGenericEvent("history_import", {{"path", importFilePath, FIELD_STRING}}); - - // Delete oldest sessions if storage budget exceeded - enforceLimits(); - - return true; -} +#include "cleaning_history.h" +#include "json_fields.h" +#include "neato_serial.h" +#include "system_manager.h" +#include +#include + +// Heatshrink decompression can drop a byte that merges two JSONL lines into +// one, so substring matching isn't enough — validate the braces balance and +// nothing trails the top-level object before embedding into /api/history. +static bool isValidMetaLine(const String& line, const char *expectedTypePrefix) { + if (line.length() < 2 || line[0] != '{' || line[line.length() - 1] != '}') + return false; + if (line.indexOf(expectedTypePrefix) < 0) + return false; + int depth = 0; + bool inString = false; + bool escape = false; + for (size_t i = 0; i < line.length(); i++) { + char c = line[i]; + if (escape) { + escape = false; + continue; + } + if (inString) { + if (c == '\\') + escape = true; + else if (c == '"') + inString = false; + continue; + } + if (c == '"') + inString = true; + else if (c == '{') + depth++; + else if (c == '}') { + depth--; + if (depth < 0) + return false; + // Reject trailing content after the top-level object closes + if (depth == 0 && i != line.length() - 1) + return false; + } + } + return depth == 0 && !inString; +} + +CleaningHistory::CleaningHistory(NeatoSerial& neato, DataLogger& logger, SystemManager& sysMgr) : + LoopTask(HISTORY_INTERVAL_IDLE_MS), neato(neato), dataLogger(logger), systemManager(sysMgr) { + TaskRegistry::add(this); +} + +void CleaningHistory::notifyCleanStart() { + if (collecting) + return; // Already recording + setInterval(HISTORY_INTERVAL_ACTIVE_MS); +} + +void CleaningHistory::tick() { + // Run incremental compression when a session just finished + if (compressing) { + if (compressStep()) { + // Compression done - remove raw source and cache metadata + compressSrc.close(); + compressDst.close(); + SPIFFS.remove(compressSrcPath); + LOG("HIST", "Compression done: %s", compressDstPath.c_str()); + + // Cache session/summary from memory (avoids decompressing on next list request) + String hsName = compressDstPath; + int lastSlash = hsName.lastIndexOf('/'); + if (lastSlash >= 0) + hsName = hsName.substring(lastSlash + 1); + metaCache[hsName] = {pendingSessionJson, pendingSummaryJson}; + pendingSessionJson = ""; + pendingSummaryJson = ""; + + compressing = false; + setInterval(HISTORY_INTERVAL_IDLE_MS); + } + return; + } + + if (fetchPending) + return; + + if (collecting) { + // Periodically flush buffered pose snapshots to disk + if (!writeBuffer.empty() && millis() - lastFlushMs >= HISTORY_FLUSH_INTERVAL_MS) { + flushWriteBuffer(); + } + collectSnapshot(); + } else { + checkState(); + enforceLimits(); + } +} + +// -- State watching (idle mode) ---------------------------------------------- + +void CleaningHistory::checkState() { + fetchPending = true; + neato.getState([this](bool ok, const RobotState& state) { + fetchPending = false; + if (!ok) + return; + + bool wasCleaning = isCleaningState(prevUiState); + bool nowCleaning = isCleaningState(state.uiState); + + // CLEANINGSUSPENDED + Charging_Cleaning means the robot is on the dock + // recharging mid-clean and will resume automatically — treat as active + bool isMidCleanRecharge = isSuspendedState(state.uiState) && state.robotState.indexOf("Charging_Cleaning") >= 0; + + // First poll after boot: if the robot is idle, finalize any orphan sessions + // left by a previous crash/reboot so they don't get merged into the next clean + if (prevUiState.isEmpty() && !nowCleaning && !isMidCleanRecharge && !recoveryAttempted) { + recoveryAttempted = true; + finalizeOrphanSessions(); + } + + if (!wasCleaning && (nowCleaning || isMidCleanRecharge) && !recoverCollection(state.uiState)) { + startCollection(state.uiState); + } + + prevUiState = state.uiState; + }); +} + +bool CleaningHistory::isCleaningState(const String& uiState) { + return uiState.indexOf("HOUSECLEANINGRUNNING") >= 0 || uiState.indexOf("HOUSECLEANINGPAUSED") >= 0 || + uiState.indexOf("SPOTCLEANINGRUNNING") >= 0 || uiState.indexOf("SPOTCLEANINGPAUSED") >= 0 || + uiState.indexOf("MANUALCLEANING") >= 0; +} + +bool CleaningHistory::isPausedState(const String& uiState) { + return uiState.indexOf("CLEANINGPAUSED") >= 0; +} + +bool CleaningHistory::isDockingState(const String& uiState) { + return uiState.indexOf("DOCKING") >= 0; +} + +bool CleaningHistory::isSuspendedState(const String& uiState) { + return uiState.indexOf("CLEANINGSUSPENDED") >= 0; +} + +String CleaningHistory::cleanModeFromState(const String& uiState) { + if (uiState.indexOf("HOUSECLEANING") >= 0) + return "house"; + if (uiState.indexOf("SPOTCLEANING") >= 0) + return "spot"; + if (uiState.indexOf("MANUALCLEANING") >= 0) + return "manual"; + return "unknown"; +} + +void CleaningHistory::resetSession() { + snapshotCount = 0; + rechargeCount = 0; + totalDistance = 0.0f; + totalRotation = 0.0f; + maxDistFromOrigin = 0.0f; + errorsDuringClean = 0; + prevHadError = false; + hasPrevPose = false; + prevX = 0.0f; + prevY = 0.0f; + prevTheta = 0.0f; + originX = 0.0f; + originY = 0.0f; + visitedCells.clear(); + cleanMode = ""; + sessionStartTime = 0; + batteryStart = -1; + recharging = false; + pendingSessionJson = ""; + pendingSummaryJson = ""; +} + +void CleaningHistory::startCollection(const String& uiState) { + collecting = true; + resetSession(); + + cleanMode = cleanModeFromState(uiState); + sessionStartTime = systemManager.now(); + + // Create session file: /history/.jsonl + activeFilePath = String(HISTORY_DIR) + "/" + String(static_cast(sessionStartTime)) + ".jsonl"; + activeFile = SPIFFS.open(activeFilePath, FILE_WRITE); + if (!activeFile) { + LOG("HIST", "Failed to create session file: %s", activeFilePath.c_str()); + collecting = false; + return; + } + + // Fetch battery level for session metadata, then write header + neato.getCharger([this](bool ok, const ChargerData& charger) { + if (ok) { + batteryStart = charger.fuelPercent; + } + writeSessionHeader(); + }); + + setInterval(HISTORY_INTERVAL_ACTIVE_MS); + LOG("HIST", "Collection started (mode: %s, file: %s)", cleanMode.c_str(), activeFilePath.c_str()); + dataLogger.logGenericEvent("history_start", {{"mode", cleanMode, FIELD_STRING}}); +} + +void CleaningHistory::stopCollection() { + // Flush any buffered snapshots before writing summary + flushWriteBuffer(); + + // Discard sessions that are too short to produce a useful map + if (snapshotCount < HISTORY_MIN_SNAPSHOTS) { + if (activeFile) { + activeFile.close(); + } + SPIFFS.remove(activeFilePath); + + LOG("HIST", "Session discarded (%u snapshots < %d minimum)", snapshotCount, HISTORY_MIN_SNAPSHOTS); + dataLogger.logGenericEvent("history_discard", {{"snapshots", String(snapshotCount), FIELD_INT}}); + + // Mark stats invalid so NotificationManager doesn't enrich a "done" + // notification with stale data from the previous session. + lastCleanStats.valid = false; + lastCleanStats.sessionId = ++sessionCounter; + + collecting = false; + recharging = false; + setInterval(HISTORY_INTERVAL_IDLE_MS); + return; + } + + // Fetch final battery level for summary + neato.getCharger([this](bool ok, const ChargerData& charger) { + int batteryEnd = ok ? charger.fuelPercent : -1; + + writeSessionSummary(batteryEnd); + + // Close the raw file + if (activeFile) { + activeFile.close(); + } + + collecting = false; + recharging = false; + setInterval(HISTORY_INTERVAL_IDLE_MS); + + float areaCovered = static_cast(visitedCells.size()) * HISTORY_AREA_CELL_M * HISTORY_AREA_CELL_M; + + // Snapshot stats for notification enrichment (survives resetSession). + // sessionId increments last so NotificationManager can detect that + // the async charger fetch above has finalized the stats. + time_t endTime = systemManager.now(); + lastCleanStats.valid = true; + lastCleanStats.mode = cleanMode; + lastCleanStats.durationSec = (sessionStartTime > 0 && endTime > sessionStartTime) + ? static_cast(endTime - sessionStartTime) + : 0; + lastCleanStats.areaCoveredM2 = areaCovered; + lastCleanStats.distanceM = totalDistance; + lastCleanStats.batteryStart = batteryStart; + lastCleanStats.batteryEnd = batteryEnd; + lastCleanStats.recharges = rechargeCount; + lastCleanStats.sessionId = ++sessionCounter; + + LOG("HIST", "Collection stopped (%u snapshots, %.1fm², %d recharges)", snapshotCount, areaCovered, + rechargeCount); + dataLogger.logGenericEvent("history_stop", {{"snapshots", String(snapshotCount), FIELD_INT}, + {"area_m2", String(areaCovered, 1), FIELD_FLOAT}, + {"recharges", String(rechargeCount), FIELD_INT}, + {"battery_end", String(batteryEnd), FIELD_INT}}); + + // Start non-blocking compression: raw .jsonl -> .jsonl.hs + compressSrcPath = activeFilePath; + compressDstPath = activeFilePath + ".hs"; + compressSrc = SPIFFS.open(compressSrcPath, FILE_READ); + compressDst = SPIFFS.open(compressDstPath, FILE_WRITE); + if (compressSrc && compressDst) { + heatshrink_encoder_reset(&compressEncoder); + compressInputDone = false; + compressing = true; + setInterval(HISTORY_COMPRESS_INTERVAL_MS); + LOG("HIST", "Starting compression: %s -> %s", compressSrcPath.c_str(), compressDstPath.c_str()); + } else { + // Compression failed - keep raw file + if (compressSrc) + compressSrc.close(); + if (compressDst) + compressDst.close(); + LOG("HIST", "Compression setup failed, keeping raw file"); + } + }); +} + +// -- Incremental compression (called from tick) ------------------------------ + +bool CleaningHistory::compressStep() { + static const size_t CHUNK_SIZE = 512; + uint8_t inBuf[CHUNK_SIZE]; + uint8_t outBuf[CHUNK_SIZE]; + + if (!compressInputDone) { + int bytesRead = compressSrc.read(inBuf, CHUNK_SIZE); + if (bytesRead <= 0) { + compressInputDone = true; + } else { + size_t offset = 0; + while (offset < static_cast(bytesRead)) { + size_t sunk = 0; + HSE_sink_res sres = + heatshrink_encoder_sink(&compressEncoder, inBuf + offset, bytesRead - offset, &sunk); + if (sres < 0) { + LOG("HIST", "Heatshrink sink error"); + compressSrc.close(); + compressDst.close(); + SPIFFS.remove(compressDstPath); + compressing = false; + return true; + } + offset += sunk; + + size_t outSz = 0; + HSE_poll_res pres; + do { + pres = heatshrink_encoder_poll(&compressEncoder, outBuf, CHUNK_SIZE, &outSz); + if (pres < 0) { + LOG("HIST", "Heatshrink poll error"); + compressSrc.close(); + compressDst.close(); + SPIFFS.remove(compressDstPath); + compressing = false; + return true; + } + if (outSz > 0) { + compressDst.write(outBuf, outSz); + } + } while (pres == HSER_POLL_MORE); + } + } + return false; + } + + // Input exhausted — finish encoding + HSE_finish_res fres = heatshrink_encoder_finish(&compressEncoder); + if (fres < 0) { + LOG("HIST", "Heatshrink finish error"); + compressSrc.close(); + compressDst.close(); + SPIFFS.remove(compressDstPath); + compressing = false; + return true; + } + + size_t outSz = 0; + HSE_poll_res pres; + do { + pres = heatshrink_encoder_poll(&compressEncoder, outBuf, CHUNK_SIZE, &outSz); + if (pres < 0) { + LOG("HIST", "Heatshrink poll error during finish"); + compressSrc.close(); + compressDst.close(); + SPIFFS.remove(compressDstPath); + compressing = false; + return true; + } + if (outSz > 0) { + compressDst.write(outBuf, outSz); + } + } while (pres == HSER_POLL_MORE); + + return (fres == HSER_FINISH_DONE); +} + +// -- Session header/summary -------------------------------------------------- + +void CleaningHistory::writeSessionHeader() { + std::vector fields = {{"type", "session", FIELD_STRING}, {"mode", cleanMode, FIELD_STRING}}; + if (sessionStartTime > 0) + fields.push_back({"time", String(static_cast(sessionStartTime)), FIELD_INT}); + if (batteryStart >= 0) + fields.push_back({"battery", String(batteryStart), FIELD_INT}); + String json = fieldsToJson(fields); + pendingSessionJson = json; + writeLine(json); +} + +void CleaningHistory::writeSessionSummary(int batteryEnd) { + time_t endTime = systemManager.now(); + long duration = + (sessionStartTime > 0 && endTime > sessionStartTime) ? static_cast(endTime - sessionStartTime) : 0; + float areaCovered = static_cast(visitedCells.size()) * HISTORY_AREA_CELL_M * HISTORY_AREA_CELL_M; + std::vector fields = {{"type", "summary", FIELD_STRING}}; + if (endTime > 0) + fields.push_back({"time", String(static_cast(endTime)), FIELD_INT}); + fields.push_back({"duration", String(duration), FIELD_INT}); + fields.push_back({"mode", cleanMode, FIELD_STRING}); + fields.push_back({"recharges", String(rechargeCount), FIELD_INT}); + fields.push_back({"snapshots", String(static_cast(snapshotCount)), FIELD_INT}); + fields.push_back({"distanceTraveled", String(totalDistance, 2), FIELD_FLOAT}); + fields.push_back({"maxDistFromOrigin", String(maxDistFromOrigin, 2), FIELD_FLOAT}); + fields.push_back({"totalRotation", String(totalRotation, 1), FIELD_FLOAT}); + fields.push_back({"areaCovered", String(areaCovered, 2), FIELD_FLOAT}); + fields.push_back({"errorsDuringClean", String(errorsDuringClean), FIELD_INT}); + if (batteryStart >= 0) + fields.push_back({"batteryStart", String(batteryStart), FIELD_INT}); + if (batteryEnd >= 0) + fields.push_back({"batteryEnd", String(batteryEnd), FIELD_INT}); + String json = fieldsToJson(fields); + pendingSummaryJson = json; + writeLine(json); +} + +// -- Buffered file writing --------------------------------------------------- +// Pose snapshots accumulate in writeBuffer; flushed every HISTORY_FLUSH_INTERVAL_MS +// or when the session ends (stopCollection / recovery). This reduces flash wear +// from one write every 2s to one write every 30s during a cleaning session. +// Session header, summary, and recharge markers flush immediately since they +// are rare one-shot writes where crash safety matters. + +void CleaningHistory::writeLine(const String& line) { + if (!activeFile) + return; + activeFile.println(line); + activeFile.flush(); +} + +void CleaningHistory::bufferLine(const String& line) { + if (!activeFile) + return; + writeBuffer.push_back(line); +} + +void CleaningHistory::flushWriteBuffer() { + if (writeBuffer.empty() || !activeFile) + return; + // Build a single string and write once to minimize SPIFFS COW metadata updates + String batch; + size_t total = 0; + for (const auto& line: writeBuffer) { + total += line.length() + 1; + } + batch.reserve(total); + for (const auto& line: writeBuffer) { + batch += line; + batch += '\n'; + } + activeFile.write(reinterpret_cast(batch.c_str()), batch.length()); + activeFile.flush(); + writeBuffer.clear(); + lastFlushMs = millis(); +} + +// -- Snapshot collection (active mode) --------------------------------------- + +static bool parsePose(const String& raw, float& x, float& y, float& theta, float& time) { + int xPos = raw.indexOf("X="); + int yPos = raw.indexOf("Y="); + int tPos = raw.indexOf("Theta="); + int tmPos = raw.indexOf("Time="); + if (xPos < 0 || yPos < 0 || tPos < 0 || tmPos < 0) + return false; + + x = raw.substring(xPos + 2).toFloat(); + y = raw.substring(yPos + 2).toFloat(); + theta = raw.substring(tPos + 6).toFloat(); + time = raw.substring(tmPos + 5).toFloat(); + return true; +} + +// Parse a single JSONL line and update session accumulators (header, recharge, pose). +// Returns true if the line was a session header. +bool CleaningHistory::replayLine(const String& line) { + auto fields = fieldsFromJson(line); + if (fields.empty()) + return false; + + const Field *typeField = findField(fields, "type"); + if (typeField && typeField->value == "session") { + const Field *modeField = findField(fields, "mode"); + if (modeField && !modeField->value.isEmpty()) + cleanMode = modeField->value; + + const Field *timeField = findField(fields, "time"); + if (timeField) { + long t = timeField->value.toInt(); + if (t > 0) + sessionStartTime = static_cast(t); + } + + const Field *battField = findField(fields, "battery"); + if (battField) + batteryStart = battField->value.toInt(); + return true; + } + + if (typeField && typeField->value == "recharge") { + rechargeCount++; + return false; + } + + // Pose snapshot line + const Field *xf = findField(fields, "x"); + const Field *yf = findField(fields, "y"); + const Field *tf = findField(fields, "t"); + if (xf && yf && tf) { + updateAccumulators(xf->value.toFloat(), yf->value.toFloat(), tf->value.toFloat()); + snapshotCount++; + } + return false; +} + +bool CleaningHistory::recoverCollection(const String& uiState) { + // Only attempt recovery once after boot — avoid scanning filesystem on every clean start + if (recoveryAttempted) + return false; + recoveryAttempted = true; + + File root = SPIFFS.open(HISTORY_DIR); + if (!root || !root.isDirectory()) + return false; + + // Collect all history filenames (both .jsonl and .jsonl.hs) and sort descending + // (newest first). Then walk from newest to oldest: collect orphan .jsonl files + // until we hit a compressed .jsonl.hs — that marks a completed session boundary, + // so everything older belongs to previous cleans and should not be touched. + // Also clean up any raw .jsonl that has a .jsonl.hs counterpart (stale leftover). + std::vector allFiles; + File entry = root.openNextFile(); + while (entry) { + String path = String(entry.path()); + if (path.endsWith(".jsonl") || path.endsWith(".jsonl.hs")) + allFiles.push_back(path); + entry = root.openNextFile(); + } + + std::sort(allFiles.begin(), allFiles.end(), [](const String& a, const String& b) { return a > b; }); + + std::vector orphans; + for (const auto& path: allFiles) { + if (path.endsWith(".jsonl.hs")) { + // Hit a compressed file — this is the boundary of a completed session. + // Stop collecting; everything older is from previous cleans. + break; + } + // Raw .jsonl — check if a compressed copy exists (stale leftover) + if (SPIFFS.exists(path + ".hs")) { + SPIFFS.remove(path); + LOG("HIST", "Removed stale raw file: %s (compressed copy exists)", path.c_str()); + continue; + } + // Check if it has a summary (completed but not yet compressed) + String firstLine, lastLine; + readFirstLastLines(path, false, firstLine, lastLine); + if (lastLine.indexOf("\"type\":\"summary\"") >= 0) + continue; + orphans.push_back(path); + } + + if (orphans.empty()) + return false; + + // orphans are newest-first; reverse to get chronological order (oldest first) + std::reverse(orphans.begin(), orphans.end()); + + // The oldest orphan becomes the target; newer ones are merged into it then deleted + String targetPath = orphans[0]; + + if (orphans.size() > 1) { + File target = SPIFFS.open(targetPath, FILE_APPEND); + if (target) { + for (size_t i = 1; i < orphans.size(); i++) { + File src = SPIFFS.open(orphans[i], FILE_READ); + if (!src) + continue; + while (src.available()) { + String line = src.readStringUntil('\n'); + line.trim(); + if (line.isEmpty()) + continue; + // Skip duplicate session headers from newer orphans + if (line.indexOf("\"type\":\"session\"") >= 0) + continue; + target.println(line); + } + src.close(); + SPIFFS.remove(orphans[i]); + LOG("HIST", "Merged orphan %s into %s", orphans[i].c_str(), targetPath.c_str()); + } + target.flush(); + target.close(); + } + } + + // Now replay the merged file to rebuild accumulators + resetSession(); + cleanMode = cleanModeFromState(uiState); + activeFilePath = targetPath; + + File recoveryFile = SPIFFS.open(targetPath, FILE_READ); + if (!recoveryFile) + return false; + + bool hasSessionHeader = false; + while (recoveryFile.available()) { + String line = recoveryFile.readStringUntil('\n'); + line.trim(); + if (line.isEmpty()) + continue; + if (replayLine(line)) + hasSessionHeader = true; + } + recoveryFile.close(); + + // Fall back to extracting start time from the filename + if (sessionStartTime <= 0) { + String name = targetPath; + int slash = name.lastIndexOf('/'); + if (slash >= 0) + name = name.substring(slash + 1); + int dot = name.indexOf('.'); + if (dot > 0) + sessionStartTime = static_cast(name.substring(0, dot).toInt()); + } + + activeFile = SPIFFS.open(activeFilePath, FILE_APPEND); + if (!activeFile) { + activeFilePath = ""; + resetSession(); + return false; + } + + collecting = true; + setInterval(HISTORY_INTERVAL_ACTIVE_MS); + LOG("HIST", "Recovered session: %s (%u snapshots, %zu orphans merged)", activeFilePath.c_str(), snapshotCount, + orphans.size()); + dataLogger.logGenericEvent("history_recover", {{"path", activeFilePath, FIELD_STRING}, + {"snapshots", String(snapshotCount), FIELD_INT}, + {"orphans", String(static_cast(orphans.size())), FIELD_INT}}); + + if (!hasSessionHeader) { + writeSessionHeader(); + } + return true; +} + +void CleaningHistory::finalizeOrphanSessions() { + // Called once at boot when the robot is idle. Finds orphan .jsonl files + // (no summary line, no compressed counterpart) and finalizes them: replay + // to compute summary stats, append summary line, then start compression. + File root = SPIFFS.open(HISTORY_DIR); + if (!root || !root.isDirectory()) + return; + + std::vector allFiles; + File entry = root.openNextFile(); + while (entry) { + String path = String(entry.path()); + if (path.endsWith(".jsonl") || path.endsWith(".jsonl.hs")) + allFiles.push_back(path); + entry = root.openNextFile(); + } + + std::sort(allFiles.begin(), allFiles.end(), [](const String& a, const String& b) { return a > b; }); + + std::vector orphans; + for (const auto& path: allFiles) { + if (path.endsWith(".jsonl.hs")) + break; // Completed session boundary + if (SPIFFS.exists(path + ".hs")) { + SPIFFS.remove(path); + continue; + } + String firstLine, lastLine; + readFirstLastLines(path, false, firstLine, lastLine); + if (lastLine.indexOf("\"type\":\"summary\"") >= 0) + continue; + orphans.push_back(path); + } + + if (orphans.empty()) + return; + + // Finalize each orphan: replay to rebuild stats, write summary, compress + for (const auto& orphanPath: orphans) { + resetSession(); + + // Replay to rebuild accumulators + File f = SPIFFS.open(orphanPath, FILE_READ); + if (!f) + continue; + while (f.available()) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.isEmpty()) + continue; + replayLine(line); + } + f.close(); + + // Extract start time from filename if not found in header + if (sessionStartTime <= 0) { + String name = orphanPath; + int slash = name.lastIndexOf('/'); + if (slash >= 0) + name = name.substring(slash + 1); + int dot = name.indexOf('.'); + if (dot > 0) + sessionStartTime = static_cast(name.substring(0, dot).toInt()); + } + + // Open for append and write summary + activeFilePath = orphanPath; + activeFile = SPIFFS.open(orphanPath, FILE_APPEND); + if (!activeFile) + continue; + + writeSessionSummary(-1); // Battery end unknown for interrupted sessions + activeFile.close(); + + LOG("HIST", "Finalized orphan: %s (%u snapshots)", orphanPath.c_str(), snapshotCount); + dataLogger.logGenericEvent("history_finalize", {{"path", orphanPath, FIELD_STRING}, + {"snapshots", String(snapshotCount), FIELD_INT}}); + + // Queue compression (only one at a time — first orphan wins, rest will + // be picked up on next boot or left as uncompressed) + if (!compressing) { + compressSrcPath = orphanPath; + compressDstPath = orphanPath + ".hs"; + compressSrc = SPIFFS.open(compressSrcPath, FILE_READ); + compressDst = SPIFFS.open(compressDstPath, FILE_WRITE); + if (compressSrc && compressDst) { + heatshrink_encoder_reset(&compressEncoder); + compressInputDone = false; + compressing = true; + setInterval(HISTORY_COMPRESS_INTERVAL_MS); + } else { + if (compressSrc) + compressSrc.close(); + if (compressDst) + compressDst.close(); + } + } + } + + activeFilePath = ""; + activeFile = File(); +} + +void CleaningHistory::collectSnapshot() { + fetchPending = true; + + neato.getState([this](bool stateOk, const RobotState& state) { + if (stateOk) { + prevUiState = state.uiState; + + bool isDocking = isDockingState(state.uiState); + bool isCleaning = isCleaningState(state.uiState); + bool isSuspended = isSuspendedState(state.uiState); + bool isChargingMidClean = state.robotState.indexOf("Charging_Cleaning") >= 0; + + // Mid-clean recharge: robot returns to base to charge before resuming. + // The UI state can be DOCKING (on the way back) or CLEANINGSUSPENDED + // (already on the dock and charging). Both combined with the + // Charging_Cleaning robot state indicate a recharge-and-resume cycle. + if ((isDocking || isSuspended) && isChargingMidClean) { + if (!recharging) { + recharging = true; + rechargeCount++; + LOG("HIST", "Recharge #%d detected — pausing collection", rechargeCount); + dataLogger.logGenericEvent("history_recharge_start", {{"count", String(rechargeCount), FIELD_INT}}); + + if (hasPrevPose) { + writeLine(fieldsToJson({{"type", "recharge", FIELD_STRING}, + {"x", String(prevX, 3), FIELD_FLOAT}, + {"y", String(prevY, 3), FIELD_FLOAT}})); + } + } + fetchPending = false; + return; + } + + if (recharging && isCleaning) { + recharging = false; + LOG("HIST", "Recharge done — resuming collection"); + dataLogger.logGenericEvent("history_recharge_end", {}); + } + + if (!isCleaning && !isDocking && !isSuspended) { + fetchPending = false; + stopCollection(); + return; + } + + // Paused — keep session open but skip snapshot collection + if (isPausedState(state.uiState)) { + fetchPending = false; + return; + } + } + + neato.getErr([this](bool errOk, const ErrorData& err) { + if (errOk) { + if (err.hasError && !prevHadError) { + errorsDuringClean++; + } + prevHadError = err.hasError; + } + + if (recharging) { + fetchPending = false; + return; + } + + neato.getRobotPos(true, [this](bool posOk, const RobotPosData& pos) { + fetchPending = false; + if (!posOk) + return; + + float x, y, theta, time; + if (!parsePose(pos.raw, x, y, theta, time)) { + LOG("HIST", "Failed to parse pose"); + return; + } + + writeSnapshot(x, y, theta, time); + }); + }); + }); +} + +void CleaningHistory::updateAccumulators(float x, float y, float theta) { + if (!hasPrevPose) { + originX = x; + originY = y; + prevX = x; + prevY = y; + prevTheta = theta; + hasPrevPose = true; + } else { + float dx = x - prevX; + float dy = y - prevY; + totalDistance += sqrtf(dx * dx + dy * dy); + + float dTheta = theta - prevTheta; + if (dTheta > 180.0f) + dTheta -= 360.0f; + if (dTheta < -180.0f) + dTheta += 360.0f; + totalRotation += fabsf(dTheta); + + prevX = x; + prevY = y; + prevTheta = theta; + } + + float dox = x - originX; + float doy = y - originY; + float distFromOrigin = sqrtf(dox * dox + doy * doy); + if (distFromOrigin > maxDistFromOrigin) { + maxDistFromOrigin = distFromOrigin; + } + + int ix = static_cast(floorf(x / HISTORY_AREA_CELL_M)); + int iy = static_cast(floorf(y / HISTORY_AREA_CELL_M)); + uint32_t cellKey = (static_cast(ix & 0xFFFF) << 16) | static_cast(iy & 0xFFFF); + visitedCells.insert(cellKey); +} + +void CleaningHistory::writeSnapshot(float x, float y, float theta, float time) { + // Localization resets to origin right before session ends — drop if the + // robot was far from origin (genuine return-to-base passes through gradually) + if (hasPrevPose && fabsf(x) < 0.001f && fabsf(y) < 0.001f && fabsf(theta) < 0.1f) { + float prevDist = sqrtf(prevX * prevX + prevY * prevY); + if (prevDist > 1.0f) { + return; + } + } + + String line = "{\"x\":" + String(x, 3) + ",\"y\":" + String(y, 3) + ",\"t\":" + String(theta, 1) + + ",\"ts\":" + String(time, 1) + "}"; + + updateAccumulators(x, y, theta); + bufferLine(line); + snapshotCount++; + + if (snapshotCount % 10 == 0) { + LOG("HIST", "Snapshot #%u (%.1fm traveled, pose: %.2f,%.2f,%.0f)", snapshotCount, totalDistance, x, y, theta); + } +} + +// -- Storage enforcement (mirrors DataLogger::enforceLimits) ----------------- + +void CleaningHistory::enforceLimits() { + // Count session files, sum directory size, and find the oldest in one pass + int fileCount = 0; + size_t histDirBytes = 0; + String oldest; + File root = SPIFFS.open(HISTORY_DIR); + if (!root || !root.isDirectory()) + return; + + File entry = root.openNextFile(); + while (entry) { + String name = String(entry.name()); + histDirBytes += entry.size(); + if (name.endsWith(".jsonl") || name.endsWith(".jsonl.hs")) { + fileCount++; + if (oldest.isEmpty() || name < oldest) { + oldest = name; + } + } + entry = root.openNextFile(); + } + + if (oldest.isEmpty()) + return; + + // History budget: total filesystem cap minus non-history data, floored at minimum reserve + size_t total = SPIFFS.totalBytes(); + size_t globalCap = (total * HISTORY_MAX_FS_PERCENT) / 100; + size_t nonHistBytes = SPIFFS.usedBytes() > histDirBytes ? SPIFFS.usedBytes() - histDirBytes : 0; + size_t available = globalCap > nonHistBytes ? globalCap - nonHistBytes : 0; + size_t minReserved = (total * HISTORY_MIN_FS_PERCENT) / 100; + size_t histBudget = available > minReserved ? available : minReserved; + + if (histDirBytes > histBudget || fileCount > HISTORY_MAX_FILES) { + String fullPath = String(HISTORY_DIR) + "/" + oldest; + LOG("HIST", "Limit: deleting %s (files=%d, histBytes=%u/%u)", fullPath.c_str(), fileCount, histDirBytes, + histBudget); + SPIFFS.remove(fullPath); + metaCache.erase(oldest); + } +} + +// -- File management (for API) ----------------------------------------------- + +void CleaningHistory::readFirstLastLines(const String& path, bool compressed, String& firstLine, String& lastLine) { + firstLine = ""; + lastLine = ""; + + if (compressed) { + // Stream-decompress keeping only the first and last lines in memory + // to avoid buffering the entire file (large sessions can exceed 100KB + // decompressed which exhausts ESP32-C3 heap). + File f = SPIFFS.open(path, FILE_READ); + if (!f) + return; + CompressedLogReader reader(std::move(f)); + uint8_t buf[256]; + size_t n; + bool firstDone = false; + // Current incomplete line being assembled from chunks + String current; + while ((n = reader.read(buf, sizeof(buf))) > 0) { + for (size_t i = 0; i < n; i++) { + char c = static_cast(buf[i]); + if (c == '\n') { + current.trim(); + if (current.length() > 0) { + if (!firstDone) { + firstLine = current; + firstDone = true; + } + // Always overwrite lastLine with the most recent non-empty line + lastLine = current; + } + current = ""; + } else { + current += c; + } + } + } + // Handle final chunk without trailing newline + current.trim(); + if (current.length() > 0) { + if (!firstDone) { + firstLine = current; + } + lastLine = current; + } + } else { + // Plain .jsonl — read first line directly, seek backward for last line + File f = SPIFFS.open(path, FILE_READ); + if (!f) + return; + // Read first line + firstLine = f.readStringUntil('\n'); + firstLine.trim(); + // Seek backward from end to find last line + size_t fileSize = f.size(); + if (fileSize < 2) { + f.close(); + return; + } + int pos = static_cast(fileSize) - 2; // Skip trailing newline + while (pos >= 0) { + f.seek(pos); + char c = static_cast(f.read()); + if (c == '\n') { + break; + } + pos--; + } + // pos is at the newline before last line, or -1 if only one line + if (pos < 0) { + f.close(); + return; // Only one line + } + f.seek(pos + 1); + lastLine = f.readStringUntil('\n'); + lastLine.trim(); + f.close(); + } +} + +std::vector CleaningHistory::listSessions() { + std::vector result; + + File root = SPIFFS.open(HISTORY_DIR); + if (!root || !root.isDirectory()) + return result; + + File entry = root.openNextFile(); + while (entry) { + String fullPath = String(entry.path()); + String name = fullPath; + int lastSlash = name.lastIndexOf('/'); + if (lastSlash >= 0) + name = name.substring(lastSlash + 1); + + if (name.endsWith(".jsonl") || name.endsWith(".jsonl.hs")) { + // During compression both raw .jsonl and partial .jsonl.hs exist — + // skip both until compression finishes and the raw source is deleted + if (compressing && (fullPath == compressSrcPath || fullPath == compressDstPath)) { + entry = root.openNextFile(); + continue; + } + + // Skip the actively collecting file during disk enumeration. + // We will manually append it at the end to avoid thread-safety / + // concurrent modification issues with filesystem iterators. + if (collecting && fullPath == activeFilePath) { + entry = root.openNextFile(); + continue; + } + + HistorySessionInfo info; + info.name = name; + info.size = entry.size(); + info.compressed = name.endsWith(".hs"); + + // Use cached metadata if available (avoids decompressing .hs files) + auto cached = metaCache.find(name); + if (cached != metaCache.end()) { + info.session = cached->second.session; + info.summary = cached->second.summary; + } else { + String firstLine, lastLine; + readFirstLastLines(fullPath, info.compressed, firstLine, lastLine); + if (isValidMetaLine(firstLine, "\"type\":\"session\"")) { + info.session = firstLine; + } + if (isValidMetaLine(lastLine, "\"type\":\"summary\"")) { + info.summary = lastLine; + } + // Cache for subsequent requests + metaCache[name] = {info.session, info.summary}; + } + + result.push_back(info); + } + entry = root.openNextFile(); + } + + // Always append the active session from memory to ensure exactly one entry + if (collecting && !activeFilePath.isEmpty()) { + String name = activeFilePath; + int lastSlash = name.lastIndexOf('/'); + if (lastSlash >= 0) + name = name.substring(lastSlash + 1); + + HistorySessionInfo info; + info.name = name; + info.size = activeFile ? activeFile.size() : 0; + info.compressed = false; + info.recording = true; + + String sessionJson = "{\"type\":\"session\",\"mode\":\"" + cleanMode + "\""; + if (sessionStartTime > 0) + sessionJson += ",\"time\":" + String(static_cast(sessionStartTime)); + if (batteryStart >= 0) + sessionJson += ",\"battery\":" + String(batteryStart); + sessionJson += "}"; + info.session = sessionJson; + // No summary for active session + + result.push_back(info); + } + + return result; +} + +std::shared_ptr CleaningHistory::readSession(const String& filename) { + String path = String(HISTORY_DIR) + "/" + filename; + + // Refuse to serve files involved in compression (partial .hs is corrupt) + if (compressing && (path == compressSrcPath || path == compressDstPath)) + return nullptr; + + if (!SPIFFS.exists(path)) + return nullptr; + + File f = SPIFFS.open(path, FILE_READ); + if (!f) + return nullptr; + + if (filename.endsWith(".hs")) { + return std::make_shared(std::move(f)); + } + return std::make_shared(std::move(f)); +} + +bool CleaningHistory::deleteSession(const String& filename) { + String path = String(HISTORY_DIR) + "/" + filename; + if (!SPIFFS.exists(path)) + return false; + metaCache.erase(filename); + return SPIFFS.remove(path); +} + +void CleaningHistory::deleteAllSessions() { + File root = SPIFFS.open(HISTORY_DIR); + if (!root || !root.isDirectory()) + return; + + std::vector paths; + File entry = root.openNextFile(); + while (entry) { + paths.push_back(String(entry.path())); + entry = root.openNextFile(); + } + + for (const auto& p: paths) { + SPIFFS.remove(p); + } + metaCache.clear(); + LOG("HIST", "Deleted %u session files", paths.size()); +} + +// -- Session import (compress-on-write from browser upload) ------------------- + +bool CleaningHistory::beginImport(const String& filename) { + importError = ""; + + if (importing) { + importError = "Import already in progress"; + return false; + } + if (collecting) { + importError = "Cannot import while recording"; + return false; + } + if (compressing) { + importError = "Cannot import while compressing"; + return false; + } + + // Validate filename pattern: .jsonl + if (!filename.endsWith(".jsonl")) { + importError = "Invalid filename"; + return false; + } + + // Check for duplicate (both raw and compressed) + String rawPath = String(HISTORY_DIR) + "/" + filename; + String hsPath = rawPath + ".hs"; + if (SPIFFS.exists(rawPath) || SPIFFS.exists(hsPath)) { + importError = "Session already exists"; + return false; + } + + // Open destination file for compressed output + importFilePath = hsPath; + importFile = SPIFFS.open(importFilePath, FILE_WRITE); + if (!importFile) { + importError = "Failed to create file"; + importFilePath = ""; + return false; + } + + heatshrink_encoder_reset(&importEncoder); + importBytesReceived = 0; + importing = true; + LOG("HIST", "Import started: %s", importFilePath.c_str()); + return true; +} + +bool CleaningHistory::writeImportChunk(const uint8_t *data, size_t len) { + if (!importing || !importFile) { + importError = "No import in progress"; + return false; + } + + importBytesReceived += len; + if (importBytesReceived > HISTORY_IMPORT_MAX_BYTES) { + importError = "File too large"; + importFile.close(); + SPIFFS.remove(importFilePath); + importing = false; + return false; + } + + static const size_t OUT_BUF_SIZE = 512; + uint8_t outBuf[OUT_BUF_SIZE]; + size_t offset = 0; + + while (offset < len) { + size_t sunk = 0; + HSE_sink_res sres = heatshrink_encoder_sink(&importEncoder, data + offset, len - offset, &sunk); + if (sres < 0) { + importError = "Compression error"; + importFile.close(); + SPIFFS.remove(importFilePath); + importing = false; + return false; + } + offset += sunk; + + // Drain compressed output + size_t outSz = 0; + HSE_poll_res pres; + do { + pres = heatshrink_encoder_poll(&importEncoder, outBuf, OUT_BUF_SIZE, &outSz); + if (pres < 0) { + importError = "Compression error"; + importFile.close(); + SPIFFS.remove(importFilePath); + importing = false; + return false; + } + if (outSz > 0) { + importFile.write(outBuf, outSz); + } + } while (pres == HSER_POLL_MORE); + } + return true; +} + +bool CleaningHistory::endImport() { + if (!importing || !importFile) { + importError = "No import in progress"; + return false; + } + + static const size_t OUT_BUF_SIZE = 512; + uint8_t outBuf[OUT_BUF_SIZE]; + + // Finish the encoder — may require multiple poll rounds + bool done = false; + while (!done) { + HSE_finish_res fres = heatshrink_encoder_finish(&importEncoder); + if (fres < 0) { + importError = "Compression finish error"; + importFile.close(); + SPIFFS.remove(importFilePath); + importing = false; + return false; + } + done = (fres == HSER_FINISH_DONE); + + size_t outSz = 0; + HSE_poll_res pres; + do { + pres = heatshrink_encoder_poll(&importEncoder, outBuf, OUT_BUF_SIZE, &outSz); + if (pres < 0) { + importError = "Compression finish error"; + importFile.close(); + SPIFFS.remove(importFilePath); + importing = false; + return false; + } + if (outSz > 0) { + importFile.write(outBuf, outSz); + } + } while (pres == HSER_POLL_MORE); + } + + importFile.close(); + importing = false; + LOG("HIST", "Import complete: %s", importFilePath.c_str()); + dataLogger.logGenericEvent("history_import", {{"path", importFilePath, FIELD_STRING}}); + + // Delete oldest sessions if storage budget exceeded + enforceLimits(); + + return true; +} diff --git a/firmware/src/cleaning_history.h b/firmware/src/cleaning_history.h index 12c987e..5db163f 100644 --- a/firmware/src/cleaning_history.h +++ b/firmware/src/cleaning_history.h @@ -1,198 +1,198 @@ -#ifndef CLEANING_HISTORY_H -#define CLEANING_HISTORY_H - -#include -#include -#include -#include -#include "config.h" -#include "data_logger.h" -#include "neato_commands.h" - -class NeatoSerial; -class SystemManager; - -// Last completed cleaning session stats — populated at end of each session, -// read by NotificationManager to enrich "cleaning done" notifications. -struct LastCleanStats { - bool valid = false; // True after at least one completed session - String mode; // "house", "spot", or "manual" - long durationSec = 0; // Cleaning duration in seconds - float areaCoveredM2 = 0.0f; // Estimated area in square meters - float distanceM = 0.0f; // Total distance traveled in meters - int batteryStart = -1; // Battery % at session start - int batteryEnd = -1; // Battery % at session end - int recharges = 0; // Mid-clean recharge count - // Bumped every time a session is finalized (success or discard) so that - // NotificationManager can detect when stopCollection's async charger fetch - // has completed and the stats above reflect the just-ended session. - uint32_t sessionId = 0; -}; - -// Session metadata returned by listSessions() — includes the raw JSON of -// the session header line and (if finished) the summary line so the frontend -// can render list cards without fetching each file's full content. -struct HistorySessionInfo { - String name; // Filename (e.g. "1771683615.jsonl.hs") - size_t size = 0; // File size in bytes - bool compressed = false; - bool recording = false; // True if this is the active recording session - String session; // Raw JSON of first line ({"type":"session",...}) - String summary; // Raw JSON of last line ({"type":"summary",...}), empty if still recording -}; - -// Records robot pose data during autonomous cleaning runs and stores each -// session as a JSONL file on SPIFFS. During collection, raw JSONL lines are -// buffered and flushed to /history/.jsonl. When cleaning ends, the -// file is compressed to .jsonl.hs via incremental heatshrink encoding -// (non-blocking, spread across tick() calls). -// -// Files are served through the same LogReader/CompressedLogReader/PlainLogReader -// abstractions used by DataLogger, so the web server streaming code is identical. - -class CleaningHistory : public LoopTask { -public: - CleaningHistory(NeatoSerial& neato, DataLogger& logger, SystemManager& sysMgr); - - // -- File management (for API, mirrors DataLogger pattern) ---------------- - - std::vector listSessions(); - std::shared_ptr readSession(const String& filename); - bool deleteSession(const String& filename); - void deleteAllSessions(); - - // Last completed session stats (for notification enrichment) - const LastCleanStats& getLastCleanStats() const { return lastCleanStats; } - - // Called by WebServer when a clean command is sent via API. - // Switches to active polling so collection starts immediately - // instead of waiting for the next idle-interval tick. - void notifyCleanStart(); - - // -- Session import (upload from browser, compress-on-write) --------------- - // Called by WebServer upload handler. Receives raw JSONL data from the browser, - // compresses it via heatshrink, and writes directly to /history/.jsonl.hs. - - // Prepare for import. Returns false with an error message if busy or file exists. - bool beginImport(const String& filename); - // Feed a chunk of raw JSONL data into the compressor and write to disk. - bool writeImportChunk(const uint8_t *data, size_t len); - // Finalize the encoder, flush remaining bytes, close file. Returns true on success. - bool endImport(); - bool isImporting() const { return importing; } - const String& getImportError() const { return importError; } - -private: - void tick() override; - - NeatoSerial& neato; - DataLogger& dataLogger; - SystemManager& systemManager; - - // -- Last session stats (survives reset, updated at end of each session) -- - LastCleanStats lastCleanStats; - uint32_t sessionCounter = 0; // Source of truth for lastCleanStats.sessionId - - // -- State tracking ------------------------------------------------------ - String prevUiState; - bool collecting = false; - bool recharging = false; - bool fetchPending = false; - bool recoveryAttempted = false; // Only try orphan recovery once after boot - size_t snapshotCount = 0; - - // Active session file (open during collection, closed at end) - File activeFile; - String activeFilePath; // e.g. "/history/1771683615.jsonl" - - // -- Session metadata ---------------------------------------------------- - String cleanMode; - time_t sessionStartTime = 0; - int batteryStart = -1; - - // -- Session accumulators ------------------------------------------------ - int rechargeCount = 0; - float totalDistance = 0.0f; - float totalRotation = 0.0f; - float maxDistFromOrigin = 0.0f; - int errorsDuringClean = 0; - bool prevHadError = false; - - // Previous pose for delta calculations - float prevX = 0.0f; - float prevY = 0.0f; - float prevTheta = 0.0f; - float originX = 0.0f; - float originY = 0.0f; - bool hasPrevPose = false; - - // Coarse area coverage — set of visited grid cells - std::set visitedCells; - - // -- End-of-session compression (incremental, non-blocking) --------------- - bool compressing = false; - File compressSrc; - File compressDst; - heatshrink_encoder compressEncoder; - bool compressInputDone = false; - String compressSrcPath; - String compressDstPath; - - bool compressStep(); // Returns true when done - - // -- Collection lifecycle ------------------------------------------------ - void checkState(); - void startCollection(const String& uiState); - void stopCollection(); - void collectSnapshot(); - void writeLine(const String& line); // Immediate write + flush (headers, summaries) - void bufferLine(const String& line); // Buffer for deferred flush (pose snapshots) - void flushWriteBuffer(); // Flush buffered lines to disk - std::vector writeBuffer; - unsigned long lastFlushMs = 0; - void writeSessionHeader(); - void writeSessionSummary(int batteryEnd); - void writeSnapshot(float x, float y, float theta, float time); - void updateAccumulators(float x, float y, float theta); - void resetSession(); - bool replayLine(const String& line); - bool recoverCollection(const String& uiState); - void finalizeOrphanSessions(); - - // Storage enforcement — delete oldest sessions when budget exceeded - void enforceLimits(); - - // -- Import state (separate from recording compression) ------------------- - bool importing = false; - File importFile; - heatshrink_encoder importEncoder; - String importFilePath; // e.g. "/history/1771683615.jsonl.hs" - String importError; - size_t importBytesReceived = 0; - - // Read first and last lines from a session file (decompresses .hs files) - static void readFirstLastLines(const String& path, bool compressed, String& firstLine, String& lastLine); - - // -- Metadata cache (avoids repeated decompression for listSessions) ------ - // Keyed by filename (e.g. "1771683615.jsonl.hs"). Populated on first list - // request and after compression/import. Entries are immutable once a session - // is finalized — invalidated only by delete/deleteAll/enforceLimits. - struct CachedMeta { - String session; // Raw JSON of session header line - String summary; // Raw JSON of summary line - }; - std::map metaCache; - - // Session/summary JSON captured during stopCollection for cache insertion - // after compression completes (avoids re-decompressing the just-written file). - String pendingSessionJson; - String pendingSummaryJson; - - static bool isCleaningState(const String& uiState); - static bool isPausedState(const String& uiState); - static bool isDockingState(const String& uiState); - static bool isSuspendedState(const String& uiState); - static String cleanModeFromState(const String& uiState); -}; - -#endif // CLEANING_HISTORY_H +#ifndef CLEANING_HISTORY_H +#define CLEANING_HISTORY_H + +#include +#include +#include +#include +#include "config.h" +#include "data_logger.h" +#include "neato_commands.h" + +class NeatoSerial; +class SystemManager; + +// Last completed cleaning session stats — populated at end of each session, +// read by NotificationManager to enrich "cleaning done" notifications. +struct LastCleanStats { + bool valid = false; // True after at least one completed session + String mode; // "house", "spot", or "manual" + long durationSec = 0; // Cleaning duration in seconds + float areaCoveredM2 = 0.0f; // Estimated area in square meters + float distanceM = 0.0f; // Total distance traveled in meters + int batteryStart = -1; // Battery % at session start + int batteryEnd = -1; // Battery % at session end + int recharges = 0; // Mid-clean recharge count + // Bumped every time a session is finalized (success or discard) so that + // NotificationManager can detect when stopCollection's async charger fetch + // has completed and the stats above reflect the just-ended session. + uint32_t sessionId = 0; +}; + +// Session metadata returned by listSessions() — includes the raw JSON of +// the session header line and (if finished) the summary line so the frontend +// can render list cards without fetching each file's full content. +struct HistorySessionInfo { + String name; // Filename (e.g. "1771683615.jsonl.hs") + size_t size = 0; // File size in bytes + bool compressed = false; + bool recording = false; // True if this is the active recording session + String session; // Raw JSON of first line ({"type":"session",...}) + String summary; // Raw JSON of last line ({"type":"summary",...}), empty if still recording +}; + +// Records robot pose data during autonomous cleaning runs and stores each +// session as a JSONL file on SPIFFS. During collection, raw JSONL lines are +// buffered and flushed to /history/.jsonl. When cleaning ends, the +// file is compressed to .jsonl.hs via incremental heatshrink encoding +// (non-blocking, spread across tick() calls). +// +// Files are served through the same LogReader/CompressedLogReader/PlainLogReader +// abstractions used by DataLogger, so the web server streaming code is identical. + +class CleaningHistory : public LoopTask { +public: + CleaningHistory(NeatoSerial& neato, DataLogger& logger, SystemManager& sysMgr); + + // -- File management (for API, mirrors DataLogger pattern) ---------------- + + std::vector listSessions(); + std::shared_ptr readSession(const String& filename); + bool deleteSession(const String& filename); + void deleteAllSessions(); + + // Last completed session stats (for notification enrichment) + const LastCleanStats& getLastCleanStats() const { return lastCleanStats; } + + // Called by WebServer when a clean command is sent via API. + // Switches to active polling so collection starts immediately + // instead of waiting for the next idle-interval tick. + void notifyCleanStart(); + + // -- Session import (upload from browser, compress-on-write) --------------- + // Called by WebServer upload handler. Receives raw JSONL data from the browser, + // compresses it via heatshrink, and writes directly to /history/.jsonl.hs. + + // Prepare for import. Returns false with an error message if busy or file exists. + bool beginImport(const String& filename); + // Feed a chunk of raw JSONL data into the compressor and write to disk. + bool writeImportChunk(const uint8_t *data, size_t len); + // Finalize the encoder, flush remaining bytes, close file. Returns true on success. + bool endImport(); + bool isImporting() const { return importing; } + const String& getImportError() const { return importError; } + +private: + void tick() override; + + NeatoSerial& neato; + DataLogger& dataLogger; + SystemManager& systemManager; + + // -- Last session stats (survives reset, updated at end of each session) -- + LastCleanStats lastCleanStats; + uint32_t sessionCounter = 0; // Source of truth for lastCleanStats.sessionId + + // -- State tracking ------------------------------------------------------ + String prevUiState; + bool collecting = false; + bool recharging = false; + bool fetchPending = false; + bool recoveryAttempted = false; // Only try orphan recovery once after boot + size_t snapshotCount = 0; + + // Active session file (open during collection, closed at end) + File activeFile; + String activeFilePath; // e.g. "/history/1771683615.jsonl" + + // -- Session metadata ---------------------------------------------------- + String cleanMode; + time_t sessionStartTime = 0; + int batteryStart = -1; + + // -- Session accumulators ------------------------------------------------ + int rechargeCount = 0; + float totalDistance = 0.0f; + float totalRotation = 0.0f; + float maxDistFromOrigin = 0.0f; + int errorsDuringClean = 0; + bool prevHadError = false; + + // Previous pose for delta calculations + float prevX = 0.0f; + float prevY = 0.0f; + float prevTheta = 0.0f; + float originX = 0.0f; + float originY = 0.0f; + bool hasPrevPose = false; + + // Coarse area coverage — set of visited grid cells + std::set visitedCells; + + // -- End-of-session compression (incremental, non-blocking) --------------- + bool compressing = false; + File compressSrc; + File compressDst; + heatshrink_encoder compressEncoder; + bool compressInputDone = false; + String compressSrcPath; + String compressDstPath; + + bool compressStep(); // Returns true when done + + // -- Collection lifecycle ------------------------------------------------ + void checkState(); + void startCollection(const String& uiState); + void stopCollection(); + void collectSnapshot(); + void writeLine(const String& line); // Immediate write + flush (headers, summaries) + void bufferLine(const String& line); // Buffer for deferred flush (pose snapshots) + void flushWriteBuffer(); // Flush buffered lines to disk + std::vector writeBuffer; + unsigned long lastFlushMs = 0; + void writeSessionHeader(); + void writeSessionSummary(int batteryEnd); + void writeSnapshot(float x, float y, float theta, float time); + void updateAccumulators(float x, float y, float theta); + void resetSession(); + bool replayLine(const String& line); + bool recoverCollection(const String& uiState); + void finalizeOrphanSessions(); + + // Storage enforcement — delete oldest sessions when budget exceeded + void enforceLimits(); + + // -- Import state (separate from recording compression) ------------------- + bool importing = false; + File importFile; + heatshrink_encoder importEncoder; + String importFilePath; // e.g. "/history/1771683615.jsonl.hs" + String importError; + size_t importBytesReceived = 0; + + // Read first and last lines from a session file (decompresses .hs files) + static void readFirstLastLines(const String& path, bool compressed, String& firstLine, String& lastLine); + + // -- Metadata cache (avoids repeated decompression for listSessions) ------ + // Keyed by filename (e.g. "1771683615.jsonl.hs"). Populated on first list + // request and after compression/import. Entries are immutable once a session + // is finalized — invalidated only by delete/deleteAll/enforceLimits. + struct CachedMeta { + String session; // Raw JSON of session header line + String summary; // Raw JSON of summary line + }; + std::map metaCache; + + // Session/summary JSON captured during stopCollection for cache insertion + // after compression completes (avoids re-decompressing the just-written file). + String pendingSessionJson; + String pendingSummaryJson; + + static bool isCleaningState(const String& uiState); + static bool isPausedState(const String& uiState); + static bool isDockingState(const String& uiState); + static bool isSuspendedState(const String& uiState); + static String cleanModeFromState(const String& uiState); +}; + +#endif // CLEANING_HISTORY_H diff --git a/firmware/src/config.h b/firmware/src/config.h index 22fb3b9..73cb72e 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -115,6 +115,18 @@ enum CommandStatus { // NVS keys — WiFi #define NVS_KEY_WIFI_SSID "wifi_ssid" #define NVS_KEY_WIFI_PASS "wifi_pass" +#define NVS_KEY_AP_FALLBACK "ap_fallback" + +// Fallback Access Point (provisioning AP) +// SSID is derived from hostname: "-ap". Network is open (no password). +// AP is automatic: always on when no STA credentials saved; on/off based on +// apFallbackOnDisconnect setting when STA connection drops. +#define AP_SSID_SUFFIX "-ap" +#define AP_DEFAULT_IP IPAddress(192, 168, 4, 1) +#define AP_GATEWAY IPAddress(192, 168, 4, 1) +#define AP_SUBNET IPAddress(255, 255, 255, 0) +#define AP_CHANNEL 1 +#define AP_MAX_CONNECTIONS 4 // NVS keys — Time/NTP #define NVS_KEY_TIMEZONE "tz" diff --git a/firmware/src/data_logger.cpp b/firmware/src/data_logger.cpp index b768ce8..f8e53f4 100644 --- a/firmware/src/data_logger.cpp +++ b/firmware/src/data_logger.cpp @@ -452,18 +452,15 @@ void DataLogger::logEvent(const String& type, const std::vector& fields) } static const char *httpMethodStr(WebRequestMethodComposite method) { - switch (method) { - case HTTP_GET: - return "GET"; - case HTTP_POST: - return "POST"; - case HTTP_DELETE: - return "DELETE"; - case HTTP_PUT: - return "PUT"; - default: - return "UNKNOWN"; - } + if (method == HTTP_GET) + return "GET"; + if (method == HTTP_POST) + return "POST"; + if (method == HTTP_DELETE) + return "DELETE"; + if (method == HTTP_PUT) + return "PUT"; + return "UNKNOWN"; } void DataLogger::logRequest(WebRequestMethodComposite method, const String& path, int status, unsigned long ms) { diff --git a/firmware/src/firmware_manager.cpp b/firmware/src/firmware_manager.cpp index f32c134..b0cd460 100644 --- a/firmware/src/firmware_manager.cpp +++ b/firmware/src/firmware_manager.cpp @@ -4,23 +4,47 @@ FirmwareManager::FirmwareManager(DataLogger& logger) : LoopTask(250), dataLogger(logger) {} -// ESP32 image extended header byte 12 contains the chip ID. -// The esp_chip_info model enum uses the same values (CHIP_ESP32=1, CHIP_ESP32S2=2, -// CHIP_ESP32C3=5, CHIP_ESP32S3=9, etc.), so we compare directly. +// ESP32 image extended header byte 12 contains the chip ID (ESP_CHIP_ID_*), +// which is a different enum from esp_chip_info_t::model (esp_chip_model_t). +// They happen to match for C3 (5) and S3 (9), but not for the original ESP32 +// (header=0, model=1) or H2. Translate explicitly before comparing. bool FirmwareManager::validateChip(uint8_t *data, size_t len) { if (len < 16) { return true; // Not enough data yet, defer validation } + struct ChipMap { + uint8_t headerId; // ESP_CHIP_ID_* from image header byte 12 + uint8_t model; // esp_chip_model_t value + }; + static const ChipMap kChipMap[] = { + {0x00, CHIP_ESP32}, + {0x02, CHIP_ESP32S2}, + {0x05, CHIP_ESP32C3}, + {0x09, CHIP_ESP32S3}, + }; + auto binChipId = static_cast(data[12]); + const ChipMap *match = nullptr; + for (const auto& entry: kChipMap) { + if (entry.headerId == binChipId) { + match = &entry; + break; + } + } + if (!match) { + updateError = "Firmware chip mismatch: unknown chip ID in image"; + LOG("FW", "Unknown binary chip ID: 0x%02X", binChipId); + return false; + } esp_chip_info_t info; esp_chip_info(&info); - auto binChipId = static_cast(data[12]); auto expected = static_cast(info.model); - if (binChipId != expected) { + if (match->model != expected) { updateError = "Firmware chip mismatch: file targets a different ESP32 variant"; - LOG("FW", "Chip mismatch: binary has chip ID %u, expected %u", binChipId, expected); + LOG("FW", "Chip mismatch: binary chip ID 0x%02X (model %u), expected model %u", binChipId, match->model, + expected); return false; } - LOG("FW", "Chip ID validated: %u", binChipId); + LOG("FW", "Chip ID validated: 0x%02X (model %u)", binChipId, match->model); return true; } diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 66ecef8..2a8c32f 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -30,7 +30,7 @@ ManualCleanManager manualClean(neatoSerial); CleaningHistory cleaningHistory(neatoSerial, dataLogger, systemManager); NotificationManager notifMgr(neatoSerial, settingsManager, dataLogger, cleaningHistory); WebServer webServer(server, neatoSerial, dataLogger, systemManager, firmwareManager, settingsManager, manualClean, - notifMgr, cleaningHistory); + notifMgr, cleaningHistory, wifiManager); // Tracks whether web server has been started (may be deferred if WiFi was slow at boot) bool webServerStarted = false; @@ -54,8 +54,12 @@ void setup() { settingsManager.onTzChange([&](const String& tz) { systemManager.applyTimezone(tz); }); settingsManager.onTxPowerChange([&](int quarterDbm) { wifiManager.setTxPower(quarterDbm); }); settingsManager.onRebootRequired([&] { systemManager.restart(); }); + settingsManager.onApFallbackChange([&](bool enabled) { wifiManager.setApFallbackOnDisconnect(enabled); }); settingsManager.begin(); + // Push initial AP fallback policy into WiFiManager before begin() + wifiManager.setApFallbackOnDisconnect(settingsManager.get().apFallbackOnDisconnect); + // Apply manual clean settings from NVS (stall threshold, motor speeds) const auto& s = settingsManager.get(); manualClean.setStallThreshold(s.stallThreshold); @@ -115,19 +119,24 @@ void setup() { dataLogger.logNtp("sync_ok", {{"epoch", String(static_cast(t)), FIELD_INT}}); }); - // Initialize web server and OTA if WiFi is already connected. - // If WiFi is slow (e.g. DHCP timeout after OTA), the web server will be - // started later in loop() once WiFi comes up — see deferred start below. - if (wifiManager.isConnected()) { + // Initialize web server and OTA if any network interface is up , STA + // (normal operation) or fallback AP (provisioning). Without either, the + // server has no socket to bind to. The deferred path in loop() picks up + // any late-arriving STA association. + if (wifiManager.isConnected() || wifiManager.isApActive()) { LOG("BOOT", "Initializing web server..."); webServer.begin(); LOG("BOOT", "Starting HTTP server..."); server.begin(); webServerStarted = true; - // Mark firmware as valid — cancels auto-rollback on next reboot - esp_ota_mark_app_valid_cancel_rollback(); - LOG("BOOT", "Firmware marked valid"); + // Mark firmware as valid , cancels auto-rollback on next reboot. + // Only cancel rollback once we have STA connectivity, otherwise an OTA + // image that broke STA but kept AP working would still be marked valid. + if (wifiManager.isConnected()) { + esp_ota_mark_app_valid_cancel_rollback(); + LOG("BOOT", "Firmware marked valid"); + } } else { LOG("BOOT", "WiFi not ready — web server will start when connected"); } @@ -161,14 +170,21 @@ void setup() { LOG("BOOT", "System initialization complete"); - // User-facing boot banner (visible in serial monitor / flash tool) - if (wifiManager.isConnected()) { - SerialMenu::printBanner("OpenNeato", FIRMWARE_VERSION, - "WiFi: " + WiFi.SSID() + " (" + WiFi.localIP().toString() + ")", - "Press 'm' for menu, 's' for status"); - } else { - SerialMenu::printBanner("OpenNeato", FIRMWARE_VERSION, "WiFi: not configured"); - } + // User-facing boot banner (visible in serial monitor / flash tool). + // Build the status line from the current WiFi state, then call + // printBanner once (a single call avoids bugprone-branch-clone warnings + // from clang-tidy for chained conditionals that all end the same way). + auto bannerStatus = [&]() -> String { + if (wifiManager.isConnected()) + return "WiFi: " + WiFi.SSID() + " (" + WiFi.localIP().toString() + ")"; + if (wifiManager.isApActive()) + return "WiFi: AP mode, connect to " + (settingsManager.get().hostname + String(AP_SSID_SUFFIX)) + + " and open http://" + WiFi.softAPIP().toString(); + return "WiFi: not configured"; + }(); + String bannerHint = + (wifiManager.isConnected() || wifiManager.isApActive()) ? "Press 'm' for menu, 's' for status" : ""; + SerialMenu::printBanner("OpenNeato", FIRMWARE_VERSION, bannerStatus, bannerHint); // Show WiFi config menu if needed if (!wifiManager.isConnected()) { @@ -189,17 +205,20 @@ void loop() { // WiFi auto-reconnect with exponential backoff wifiManager.loop(); - // Deferred web server start — if WiFi was slow at boot (e.g. DHCP timeout - // after OTA), start the web server once WiFi eventually connects. - if (!webServerStarted && wifiManager.isConnected()) { - LOG("MAIN", "WiFi connected late — starting web server now"); + // Deferred web server start , if WiFi was slow at boot (e.g. DHCP timeout + // after OTA), start the web server once WiFi eventually connects, or once + // the fallback AP comes up so the user can reconfigure WiFi from a browser. + if (!webServerStarted && (wifiManager.isConnected() || wifiManager.isApActive())) { + LOG("MAIN", "WiFi available late , starting web server now"); dataLogger.logWifi("deferred_start"); webServer.begin(); server.begin(); webServerStarted = true; - esp_ota_mark_app_valid_cancel_rollback(); - LOG("MAIN", "Firmware marked valid (deferred)"); + if (wifiManager.isConnected()) { + esp_ota_mark_app_valid_cancel_rollback(); + LOG("MAIN", "Firmware marked valid (deferred)"); + } } // Check for button press (runtime reset) @@ -227,8 +246,10 @@ void loop() { } } - // Firmware update handling (only if connected) - if (wifiManager.isConnected()) { + // Firmware update handling , runs while either STA or fallback AP is up so + // the user can recover from a broken STA config by uploading firmware over + // the AP. + if (wifiManager.isConnected() || wifiManager.isApActive()) { firmwareManager.loop(); // Skip other operations during firmware update diff --git a/firmware/src/neato_commands.cpp b/firmware/src/neato_commands.cpp index 96a9ce5..2b0d815 100644 --- a/firmware/src/neato_commands.cpp +++ b/firmware/src/neato_commands.cpp @@ -168,8 +168,9 @@ static bool findCsvValue(const String& raw, const String& label, String& value) if (key == label) { value = line.substring(comma + 1); value.trim(); - // Strip trailing comma if present (GetAnalogSensors format) - if (value.endsWith(",")) { + // Strip trailing commas (GetAnalogSensors and GetVersion formats + // may have one or more trailing commas for empty fields) + while (value.endsWith(",")) { value = value.substring(0, value.length() - 1); } return true; @@ -190,6 +191,14 @@ std::vector VersionData::toFields() const { {"ldsVersion", ldsVersion, FIELD_STRING}, {"ldsSerial", ldsSerial, FIELD_STRING}, {"mainBoardVersion", mainBoardVersion, FIELD_STRING}, + {"smartBatteryAuthorization", String(smartBatteryAuthorization), FIELD_INT}, + {"smartBatteryDataVersion", String(smartBatteryDataVersion), FIELD_INT}, + {"smartBatteryChemistry", smartBatteryChemistry, FIELD_STRING}, + {"smartBatteryDeviceName", smartBatteryDeviceName, FIELD_STRING}, + {"smartBatteryManufacturerName", smartBatteryManufacturerName, FIELD_STRING}, + {"smartBatteryMfgDate", smartBatteryMfgDate, FIELD_STRING}, + {"smartBatterySerialNumber", smartBatterySerialNumber, FIELD_STRING}, + {"smartBatterySoftwareVersion", smartBatterySoftwareVersion, FIELD_STRING}, }; } @@ -212,6 +221,23 @@ std::vector ChargerData::toFields() const { }; } +std::vector BatteryAnalogData::toFields() const { + return { + {"batteryVoltageV", String(batteryVoltageV, 3), FIELD_FLOAT}, + {"batteryCurrentMA", String(batteryCurrentMA), FIELD_INT}, + {"batteryTemperatureC", String(batteryTemperatureC, 2), FIELD_FLOAT}, + {"externalVoltageV", String(externalVoltageV, 3), FIELD_FLOAT}, + }; +} + +std::vector BatteryWarrantyData::toFields() const { + return { + {"cumulativeCleaningTimeSeconds", String(cumulativeCleaningTimeSeconds), FIELD_INT}, + {"cumulativeBatteryCycles", String(cumulativeBatteryCycles), FIELD_INT}, + {"validationCode", validationCode, FIELD_STRING}, + }; +} + std::vector DigitalSensorData::toFields() const { return { {"dcJackIn", dcJackIn ? "true" : "false", FIELD_BOOL}, @@ -325,6 +351,24 @@ bool parseVersionData(const String& raw, VersionData& out) { out.mainBoardVersion = val; out.mainBoardVersion.replace(",", "."); } + if (findCsvValue(raw, "SmartBatt Authorization", val)) + out.smartBatteryAuthorization = val.toInt(); + if (findCsvValue(raw, "SmartBatt Data Version", val)) + out.smartBatteryDataVersion = val.toInt(); + if (findCsvValue(raw, "SmartBatt Device Chemistry", val)) + out.smartBatteryChemistry = val; + if (findCsvValue(raw, "SmartBatt Device Name", val)) + out.smartBatteryDeviceName = val; + if (findCsvValue(raw, "SmartBatt Manufacturer Name", val)) + out.smartBatteryManufacturerName = val; + if (findCsvValue(raw, "SmartBatt Mfg Year/Month/Day", val)) { + val.replace(",", "-"); + out.smartBatteryMfgDate = val; + } + if (findCsvValue(raw, "SmartBatt Serial Number", val)) + out.smartBatterySerialNumber = val; + if (findCsvValue(raw, "SmartBatt Software Version", val)) + out.smartBatterySoftwareVersion = val; // Parse "Time UTC" field, format: "Sat Apr 11 19:26:13 2026" if (findCsvValue(raw, "Time UTC", val)) { val.trim(); @@ -361,6 +405,63 @@ bool parseVersionData(const String& raw, VersionData& out) { return out.modelName.length() > 0 || out.softwareVersion.length() > 0; } +static int parseHexValue(const String& value) { + char *end = nullptr; + long out = strtol(value.c_str(), &end, 16); + if (end == value.c_str()) + return 0; + return static_cast(out); +} + +static String csvLastField(const String& value) { + int comma = value.lastIndexOf(','); + if (comma < 0) + return value; + String last = value.substring(comma + 1); + last.trim(); + return last; +} + +bool parseBatteryAnalogData(const String& raw, BatteryAnalogData& out) { + String val; + bool found = false; + if (findCsvValue(raw, "BatteryVoltage", val)) { + out.batteryVoltageV = csvLastField(val).toFloat() / 1000.0f; + found = true; + } + if (findCsvValue(raw, "BatteryCurrent", val)) { + out.batteryCurrentMA = csvLastField(val).toInt(); + found = true; + } + if (findCsvValue(raw, "BatteryTemperature", val)) { + out.batteryTemperatureC = csvLastField(val).toFloat() / 1000.0f; + found = true; + } + if (findCsvValue(raw, "ExternalVoltage", val)) { + out.externalVoltageV = csvLastField(val).toFloat() / 1000.0f; + found = true; + } + return found; +} + +bool parseBatteryWarrantyData(const String& raw, BatteryWarrantyData& out) { + String val; + bool found = false; + if (findCsvValue(raw, "CumulativeCleaningTimeInSecs", val)) { + out.cumulativeCleaningTimeSeconds = parseHexValue(val); + found = true; + } + if (findCsvValue(raw, "CumulativeBatteryCycles", val)) { + out.cumulativeBatteryCycles = parseHexValue(val); + found = true; + } + if (findCsvValue(raw, "ValidationCode", val)) { + out.validationCode = val; + found = true; + } + return found; +} + bool parseChargerData(const String& raw, ChargerData& out) { String val; if (findCsvValue(raw, "FuelPercent", val)) diff --git a/firmware/src/neato_commands.h b/firmware/src/neato_commands.h index 4b74f57..ac36aaf 100644 --- a/firmware/src/neato_commands.h +++ b/firmware/src/neato_commands.h @@ -9,6 +9,7 @@ #define CMD_GET_VERSION "GetVersion" #define CMD_GET_CHARGER "GetCharger" +#define CMD_GET_ANALOG_SENSORS "GetAnalogSensors" #define CMD_GET_DIGITAL_SENSORS "GetDigitalSensors" #define CMD_GET_MOTORS "GetMotors" #define CMD_GET_STATE "GetState" @@ -46,6 +47,7 @@ #define CMD_GET_USER_SETTINGS "GetUserSettings" #define CMD_SET_USER_SETTINGS "SetUserSettings" #define CMD_SET_NAVIGATION_MODE "SetNavigationMode" +#define CMD_NEW_BATTERY "NewBattery" // -- Sound IDs --------------------------------------------------------------- @@ -83,6 +85,14 @@ struct VersionData : public JsonSerializable { String ldsSerial; String mainBoardVersion; time_t timeUtc = 0; // Parsed from "Time UTC" field (0 = not available) + int smartBatteryAuthorization = 0; + int smartBatteryDataVersion = 0; + String smartBatteryChemistry; + String smartBatteryDeviceName; + String smartBatteryManufacturerName; + String smartBatteryMfgDate; + String smartBatterySerialNumber; + String smartBatterySoftwareVersion; std::vector toFields() const override; }; @@ -106,6 +116,23 @@ struct ChargerData : public JsonSerializable { std::vector toFields() const override; }; +struct BatteryAnalogData : public JsonSerializable { + float batteryVoltageV = 0.0f; + int batteryCurrentMA = 0; + float batteryTemperatureC = 0.0f; + float externalVoltageV = 0.0f; + + std::vector toFields() const override; +}; + +struct BatteryWarrantyData : public JsonSerializable { + int cumulativeCleaningTimeSeconds = 0; + int cumulativeBatteryCycles = 0; + String validationCode; + + std::vector toFields() const override; +}; + struct DigitalSensorData : public JsonSerializable { bool dcJackIn = false; bool dustbinIn = false; @@ -211,6 +238,8 @@ struct RobotPosData : public JsonSerializable { bool parseVersionData(const String& raw, VersionData& out); bool parseChargerData(const String& raw, ChargerData& out); +bool parseBatteryAnalogData(const String& raw, BatteryAnalogData& out); +bool parseBatteryWarrantyData(const String& raw, BatteryWarrantyData& out); bool parseDigitalSensorData(const String& raw, DigitalSensorData& out); bool parseMotorData(const String& raw, MotorData& out); bool parseRobotState(const String& raw, RobotState& out); diff --git a/firmware/src/neato_serial.cpp b/firmware/src/neato_serial.cpp index 4be16fc..56402e7 100644 --- a/firmware/src/neato_serial.cpp +++ b/firmware/src/neato_serial.cpp @@ -1,733 +1,793 @@ -#include "neato_serial.h" -#include "config.h" - -// -- Lifecycle --------------------------------------------------------------- - -// Helper: create a cache hit lambda that fires loggerCallback with cached=true. -// Captures `this` so it reads loggerCallback at call time (works before setLogger). -#define CACHE_HIT(CMD) \ - [this](unsigned long ageMs) { \ - if (loggerCallback) \ - loggerCallback(CMD, CMD_SUCCESS, 0, "", 0, 0, ageMs); \ - } - -NeatoSerial::NeatoSerial() : - LoopTask(0), versionCache( - CACHE_TTL_VERSION, [this](AsyncCache::Callback cb) { fetchVersion(cb); }, - CACHE_HIT(CMD_GET_VERSION)), - chargerCache( - CACHE_TTL_CHARGER, [this](AsyncCache::Callback cb) { fetchCharger(cb); }, - CACHE_HIT(CMD_GET_CHARGER)), - digitalCache( - CACHE_TTL_SENSORS, [this](AsyncCache::Callback cb) { fetchDigitalSensors(cb); }, - CACHE_HIT(CMD_GET_DIGITAL_SENSORS)), - motorCache( - CACHE_TTL_SENSORS, [this](AsyncCache::Callback cb) { fetchMotors(cb); }, - CACHE_HIT(CMD_GET_MOTORS)), - stateCache( - CACHE_TTL_STATE, [this](AsyncCache::Callback cb) { fetchState(cb); }, CACHE_HIT(CMD_GET_STATE)), - errCache( - CACHE_TTL_STATE, [this](AsyncCache::Callback cb) { fetchErr(cb); }, CACHE_HIT(CMD_GET_ERR)), - ldsCache( - CACHE_TTL_LDS, [this](AsyncCache::Callback cb) { fetchLdsScan(cb); }, - CACHE_HIT(CMD_GET_LDS_SCAN)), - robotPosRawCache( - CACHE_TTL_SENSORS, - [this](AsyncCache::Callback cb) { fetchRobotPos(CMD_GET_ROBOT_POS_RAW, cb); }, - CACHE_HIT(CMD_GET_ROBOT_POS_RAW)), - robotPosSmoothCache( - CACHE_TTL_SENSORS, - [this](AsyncCache::Callback cb) { fetchRobotPos(CMD_GET_ROBOT_POS_SMOOTH, cb); }, - CACHE_HIT(CMD_GET_ROBOT_POS_SMOOTH)), - userSettingsCache( - CACHE_TTL_VERSION, [this](AsyncCache::Callback cb) { fetchUserSettings(cb); }, - CACHE_HIT(CMD_GET_USER_SETTINGS)) { - TaskRegistry::add(this); -} - -#undef CACHE_HIT - -void NeatoSerial::begin(int txPin, int rxPin) { - uart.setRxBufferSize(NEATO_UART_RX_BUFFER); - uart.begin(NEATO_BAUD_RATE, SERIAL_8N1, rxPin, txPin); - LOG("NEATO", "UART initialized (TX=GPIO%d, RX=GPIO%d, baud=%d)", txPin, rxPin, NEATO_BAUD_RATE); -} - -void NeatoSerial::initSKey() { - sKeyPending = true; - versionCache.invalidate(); // Force a fresh fetch (don't serve a stale failure) - getVersion([this](bool ok, const VersionData& v) { - if (!ok || v.serialNumber.length() == 0) { - LOG("NEATO", "SKey init failed — GetVersion returned no serial, retrying in %lu ms", sKeyRetryDelay); - sKeyRetryAt = millis() + sKeyRetryDelay; - sKeyRetryDelay = (sKeyRetryDelay * 2 < SKEY_RETRY_MAX_MS) ? sKeyRetryDelay * 2 : SKEY_RETRY_MAX_MS; - return; - } - sKeyPending = false; - robotModelName = v.modelName; - LOG("NEATO", "Model: %s (supported=%s)", robotModelName.c_str(), - isSupportedModel(robotModelName) ? "yes" : "no"); - sKey = computeSKey(v.serialNumber); - if (sKey.length() > 0) { - LOG("NEATO", "SKey computed (%d chars) from serial %s", sKey.length(), v.serialNumber.c_str()); - } else { - LOG("NEATO", "SKey computation failed for serial: %s", v.serialNumber.c_str()); - } - }); -} - -String NeatoSerial::buildSetEvent(const char *event) const { - return String(CMD_SET_EVENT_PREFIX) + event + CMD_SET_EVENT_SKEY + sKey; -} - -void NeatoSerial::tick() { - // Drive initSKey lifecycle: first attempt + retries on failure. - // Runs inside tick() (not setup()) so the UART state machine is already - // processing the queue — avoids the race where GetVersion was enqueued - // in setup() but tick() hadn't started yet. - if (sKeyPending && millis() >= sKeyRetryAt) { - sKeyRetryAt = ULONG_MAX; // Prevent re-entry while fetch is in flight - initSKey(); - } - - switch (state) { - case QUEUE_IDLE: - if (!queue.empty()) { - dequeueNext(); - } - break; - - case QUEUE_SENDING: - sendCurrentCommand(); - break; - - case QUEUE_WAITING_RESPONSE: { - // Read all available bytes - while (uart.available()) { - char c = static_cast(uart.read()); - if (c == NEATO_RESPONSE_TERMINATOR) { - unsigned long elapsed = millis() - commandSentAt; - LOG("NEATO", "RX: %u bytes in %lu ms", responseBuffer.length(), elapsed); - - // Validate: response must echo the sent command on the first line. - // If it doesn't, the UART stream is desynced (we got a response - // meant for a different command). Flush and fail this request so - // the next command starts clean. - if (!validateResponseEcho(responseBuffer)) { - LOG("NEATO", "DESYNC: sent '%s' but response echoed a different command, flushing", - currentCommand.c_str()); - flushUartRx(); - completeCommand(CMD_SERIAL_ERROR, responseBuffer); - return; - } - - // Detect "Unknown Cmd" — robot doesn't support this command - if (responseBuffer.indexOf("Unknown Cmd") >= 0) { - LOG("NEATO", "Unsupported: %s", currentCommand.c_str()); - completeCommand(CMD_UNSUPPORTED, responseBuffer); - return; - } - - completeCommand(CMD_SUCCESS, responseBuffer); - return; - } - responseBuffer += c; - } - // Check timeout - if (millis() - commandSentAt >= NEATO_CMD_TIMEOUT_MS) { - LOG("NEATO", "Timeout: %s (%lu ms, partial: %u bytes)", currentCommand.c_str(), - (unsigned long) NEATO_CMD_TIMEOUT_MS, responseBuffer.length()); - // Log partial response on timeout (useful for debugging serial issues). - // Flush UART to prevent stale bytes from leaking into the next command. - flushUartRx(); - completeCommand(CMD_TIMEOUT, responseBuffer); - } - break; - } - - case QUEUE_INTER_DELAY: - if (millis() - delayStartedAt >= NEATO_INTER_CMD_DELAY_MS) { - state = QUEUE_IDLE; - } - break; - } -} - -// -- Queue management -------------------------------------------------------- - -bool NeatoSerial::enqueue(const String& command, std::function callback, - CommandPriority priority) { - if (static_cast(queue.size()) >= NEATO_QUEUE_MAX_SIZE) { - LOG("NEATO", "Queue full, rejecting: %s", command.c_str()); - if (loggerCallback) - loggerCallback(command, CMD_QUEUE_FULL, 0, "", static_cast(queue.size()), 0, 0); - if (callback) - callback(false, ""); - return false; - } - // Lower number = higher priority. Keep FIFO order within same priority. - auto it = queue.begin(); - for (; it != queue.end(); ++it) { - if (it->priority > priority) - break; - } - queue.insert(it, {command, static_cast(priority), callback}); - return true; -} - -std::function NeatoSerial::wrapAction(std::function callback) { - if (!callback) - return nullptr; - return [callback](bool ok, const String&) { callback(ok); }; -} - -void NeatoSerial::dequeueNext() { - if (queue.empty()) - return; - - // Capture queue depth before dequeue (for logging) - queueDepthAtStart = static_cast(queue.size()); - - CommandEntry entry = queue.front(); - queue.erase(queue.begin()); - - currentCommand = entry.command; - currentCallback = entry.callback; - responseBuffer = ""; - - state = QUEUE_SENDING; -} - -void NeatoSerial::flushUartRx() { - int flushed = 0; - while (uart.available()) { - uart.read(); - flushed++; - } - if (flushed > 0) { - LOG("NEATO", "Flushed %d stale bytes from UART RX", flushed); - } -} - -void NeatoSerial::sendCurrentCommand() { - // Drain any stale bytes from a previous response that may have arrived late - flushUartRx(); - - LOG("NEATO", "TX: %s", currentCommand.c_str()); - uart.print(currentCommand + "\n"); - commandSentAt = millis(); - state = QUEUE_WAITING_RESPONSE; -} - -bool NeatoSerial::validateResponseEcho(const String& response) const { - // The Neato echoes the command on the first line of the response, - // e.g. "GetCharger\r\nLabel,Value\r\n...". Extract the first word of the - // sent command (before any space/flag) and check the response starts with it. - // Some commands echo only the name ("Clean" for "Clean House"), while others - // echo the full command with arguments ("TestMode On" for "TestMode On"). - // Using startsWith handles both cases. - String expectedEcho = currentCommand; - int spacePos = expectedEcho.indexOf(' '); - if (spacePos > 0) - expectedEcho = expectedEcho.substring(0, spacePos); - - // Find the first line in the response (up to \r or \n) - String firstLine = response; - int crPos = firstLine.indexOf('\r'); - int lfPos = firstLine.indexOf('\n'); - int lineEnd = -1; - if (crPos >= 0 && lfPos >= 0) - lineEnd = (crPos < lfPos) ? crPos : lfPos; - else if (crPos >= 0) - lineEnd = crPos; - else if (lfPos >= 0) - lineEnd = lfPos; - if (lineEnd >= 0) - firstLine = firstLine.substring(0, lineEnd); - - // Check that the echo line starts with the expected command name (case-insensitive). - firstLine.trim(); - expectedEcho.trim(); - firstLine.toLowerCase(); - expectedEcho.toLowerCase(); - return firstLine.startsWith(expectedEcho); -} - -void NeatoSerial::completeCommand(CommandStatus status, const String& response) { - unsigned long elapsed = millis() - commandSentAt; - String cmd = currentCommand; - auto cb = currentCallback; - int qDepth = queueDepthAtStart; - String resp = response; // Copy before clearing responseBuffer (response is a ref to it) - size_t respBytes = resp.length(); - - currentCommand = ""; - currentCallback = nullptr; - responseBuffer = ""; - queueDepthAtStart = 0; - - // Start inter-command delay before next command - delayStartedAt = millis(); - state = QUEUE_INTER_DELAY; - - // Fire logger hook before user callback with enhanced metadata - if (loggerCallback) - loggerCallback(cmd, status, elapsed, resp, qDepth, respBytes, 0); - - // User callback still gets simple bool for backward compatibility - bool success = (status == CMD_SUCCESS); - if (cb) - cb(success, resp); -} - -// -- Cached sensor query methods (public API) -------------------------------- -// These delegate to AsyncCache, which handles TTL, dedup, and coalescing. - -void NeatoSerial::getVersion(std::function callback) { - versionCache.get(callback); -} - -void NeatoSerial::getCharger(std::function callback) { - chargerCache.get(callback); -} - -void NeatoSerial::getUserSettings(std::function callback) { - userSettingsCache.get(callback); -} - -void NeatoSerial::getDigitalSensors(std::function callback) { - getDigitalSensors(callback, PRIORITY_NORMAL); -} - -void NeatoSerial::getDigitalSensors(std::function callback, - CommandPriority priority) { - // Normal priority goes through cache; elevated priority bypasses it - // to guarantee commands are enqueued at the requested priority. - if (priority == PRIORITY_NORMAL) { - digitalCache.get(callback); - } else { - fetchDigitalSensors(callback, priority); - } -} - -void NeatoSerial::getMotors(std::function callback) { - getMotors(callback, PRIORITY_NORMAL); -} - -void NeatoSerial::getMotors(std::function callback, CommandPriority priority) { - // Normal priority goes through cache; elevated priority bypasses it - // to guarantee commands are enqueued at the requested priority. - if (priority == PRIORITY_NORMAL) { - motorCache.get(callback); - } else { - fetchMotors(callback, priority); - } -} - -void NeatoSerial::getState(std::function callback) { - // During manual clean, the robot reports UIMGR_STATE_TESTMODE. - // Override to UIMGR_STATE_MANUALCLEANING so callers (frontend, dashboard) - // see the correct pseudo-state without needing to know about TestMode internals. - if (manualCleanActive) { - stateCache.get([callback](bool ok, const RobotState& data) { - if (!ok || !callback) { - if (callback) - callback(ok, data); - return; - } - RobotState patched = data; - patched.uiState = "UIMGR_STATE_MANUALCLEANING"; - callback(true, patched); - }); - return; - } - stateCache.get(callback); -} - -void NeatoSerial::getErr(std::function callback) { - errCache.get(callback); -} - -void NeatoSerial::getErrClear(std::function callback) { - // getErrClear is never cached — it clears the error and always needs a fresh fetch - fetchErrClear(callback); -} - -void NeatoSerial::getLdsScan(std::function callback) { - ldsCache.get(callback); -} - -void NeatoSerial::getRobotPos(bool smooth, std::function callback) { - (smooth ? robotPosSmoothCache : robotPosRawCache).get(callback); -} - -// -- Raw fetch methods (enqueue serial command, parse response) --------------- - -void NeatoSerial::fetchVersion(std::function callback) { - enqueue(CMD_GET_VERSION, [callback](bool ok, const String& raw) { - VersionData data; - if (ok) - ok = parseVersionData(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchCharger(std::function callback) { - enqueue(CMD_GET_CHARGER, [callback](bool ok, const String& raw) { - ChargerData data; - if (ok) - ok = parseChargerData(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchDigitalSensors(std::function callback, - CommandPriority priority) { - enqueue( - CMD_GET_DIGITAL_SENSORS, - [callback](bool ok, const String& raw) { - DigitalSensorData data; - if (ok) - ok = parseDigitalSensorData(raw, data); - if (callback) - callback(ok, data); - }, - priority); -} - -void NeatoSerial::fetchMotors(std::function callback, CommandPriority priority) { - enqueue( - CMD_GET_MOTORS, - [callback](bool ok, const String& raw) { - MotorData data; - if (ok) - ok = parseMotorData(raw, data); - if (callback) - callback(ok, data); - }, - priority); -} - -void NeatoSerial::fetchState(std::function callback) { - enqueue(CMD_GET_STATE, [callback](bool ok, const String& raw) { - RobotState data; - if (ok) - ok = parseRobotState(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchErr(std::function callback) { - enqueue(CMD_GET_ERR, [callback](bool ok, const String& raw) { - ErrorData data; - if (ok) - ok = parseErrorData(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchErrClear(std::function callback) { - enqueue(CMD_GET_ERR_CLEAR, [callback](bool ok, const String& raw) { - ErrorData data; - if (ok) - ok = parseErrorData(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchLdsScan(std::function callback) { - enqueue(CMD_GET_LDS_SCAN, [callback](bool ok, const String& raw) { - LdsScanData data; - if (ok) - ok = parseLdsScanData(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchRobotPos(const char *cmd, std::function callback) { - enqueue(cmd, [callback](bool ok, const String& raw) { - RobotPosData data; - if (ok) - ok = parseRobotPosData(raw, data); - if (callback) - callback(ok, data); - }); -} - -void NeatoSerial::fetchUserSettings(std::function callback) { - enqueue(CMD_GET_USER_SETTINGS, [callback](bool ok, const String& raw) { - UserSettingsData data; - if (ok) - ok = parseUserSettingsData(raw, data); - if (callback) - callback(ok, data); - }); -} - -// -- Cache invalidation ------------------------------------------------------ - -void NeatoSerial::invalidateState() { - stateCache.invalidate(); - errCache.invalidate(); -} - -void NeatoSerial::invalidateAll() { - versionCache.invalidate(); - chargerCache.invalidate(); - digitalCache.invalidate(); - motorCache.invalidate(); - stateCache.invalidate(); - errCache.invalidate(); - ldsCache.invalidate(); - robotPosRawCache.invalidate(); - robotPosSmoothCache.invalidate(); - userSettingsCache.invalidate(); -} - -// -- Action command convenience methods -------------------------------------- - -bool NeatoSerial::clean(const String& action, std::function callback) { - // All cleaning control uses SetEvent — the authenticated event API that D3-D7 - // robots use for their cloud/app protocol. This correctly transitions the UI - // state machine and preserves map/localization during pause/resume. - // - // SKey must be computed at boot via initSKey(). If missing, commands will fail - // gracefully (callback with false). - - if (!hasSKey()) { - LOG("NEATO", "clean(%s) failed — SKey not available", action.c_str()); - if (callback) - callback(false); - return false; - } - - if (action == "dock") { - invalidateState(); - return enqueue(buildSetEvent(EVT_SEND_TO_BASE), wrapAction(callback), PRIORITY_HIGH); - } - - if (action == "pause") { - invalidateState(); - return enqueue(buildSetEvent(EVT_PAUSE), wrapAction(callback), PRIORITY_HIGH); - } - - if (action == "stop") { - invalidateState(); - return enqueue(buildSetEvent(EVT_STOP), wrapAction(callback), PRIORITY_HIGH); - } - - // Type-aware resume: "house" only resumes a paused house clean, "spot" - // only resumes a paused spot clean. Prevents HA "start" from resuming - // a previously paused spot clean when the user expects a new house clean. - if (action == "house") { - bool isPausedHouse = - stateCache.hasCached() && stateCache.getCached().uiState.indexOf("HOUSECLEANINGPAUSED") >= 0; - if (isPausedHouse) { - invalidateState(); - return enqueue(buildSetEvent(EVT_RESUME), wrapAction(callback), PRIORITY_HIGH); - } - invalidateState(); - if (cleanStartCallback) - cleanStartCallback(); - // Send navigation mode first (fire-and-forget); house clean proceeds regardless. - if (navModeGetter) { - String mode = navModeGetter(); - if (mode.length() > 0 && mode != "Normal") { - String navCmd = String(CMD_SET_NAVIGATION_MODE) + " " + mode; - enqueue(navCmd, nullptr, PRIORITY_HIGH); - } - } - return enqueue(buildSetEvent(EVT_START_HOUSE), wrapAction(callback), PRIORITY_HIGH); - } - - if (action == "spot") { - bool isPausedSpot = - stateCache.hasCached() && stateCache.getCached().uiState.indexOf("SPOTCLEANINGPAUSED") >= 0; - if (isPausedSpot) { - invalidateState(); - return enqueue(buildSetEvent(EVT_RESUME), wrapAction(callback), PRIORITY_HIGH); - } - invalidateState(); - if (cleanStartCallback) - cleanStartCallback(); - return enqueue(buildSetEvent(EVT_START_SPOT), wrapAction(callback), PRIORITY_HIGH); - } - - // Generic action — resume whatever is paused, or start house clean - bool isPaused = stateCache.hasCached() && stateCache.getCached().uiState.indexOf("CLEANINGPAUSED") >= 0; - if (isPaused) { - invalidateState(); - return enqueue(buildSetEvent(EVT_RESUME), wrapAction(callback), PRIORITY_HIGH); - } - - // New clean from idle — fall through to house clean - invalidateState(); - if (cleanStartCallback) - cleanStartCallback(); - // Send navigation mode first (fire-and-forget); house clean proceeds regardless. - if (navModeGetter) { - String mode = navModeGetter(); - if (mode.length() > 0 && mode != "Normal") { - String navCmd = String(CMD_SET_NAVIGATION_MODE) + " " + mode; - enqueue(navCmd, nullptr, PRIORITY_HIGH); - } - } - return enqueue(buildSetEvent(EVT_START_HOUSE), wrapAction(callback), PRIORITY_HIGH); -} - -bool NeatoSerial::testMode(bool enable, std::function callback) { - const char *cmd = enable ? CMD_TEST_MODE_ON : CMD_TEST_MODE_OFF; - invalidateState(); - // HIGH priority: TestMode is a prerequisite for motor commands — must execute - // before any CRITICAL/MEDIUM motor commands that may already be queued. - return enqueue(cmd, wrapAction(callback), PRIORITY_HIGH); -} - -bool NeatoSerial::playSound(SoundId soundId, std::function callback) { - String cmd = String(CMD_PLAY_SOUND) + " SoundID " + String(static_cast(soundId)); - return enqueue(cmd, wrapAction(callback)); -} - -bool NeatoSerial::setLdsRotation(bool on, std::function callback) { - const char *cmd = on ? CMD_SET_LDS_ROTATION_ON : CMD_SET_LDS_ROTATION_OFF; - // HIGH priority: LDS rotation is a setup/teardown command that must execute - // before motor commands during manual clean enable/disable sequences. - return enqueue(cmd, wrapAction(callback), PRIORITY_HIGH); -} - -bool NeatoSerial::setMotorWheels(int leftMM, int rightMM, int speedMMs, std::function callback) { - // SetMotor 0 0 0 bug: zero values are ignored, robot coasts for ~1s. - // Workaround: disable wheels for immediate stop, then re-enable for next command. - if (leftMM == 0 && rightMM == 0) { - return enqueue( - String(CMD_SET_MOTOR) + " LWheelDisable RWheelDisable", - [this, callback](bool ok, const String&) { - // Re-enable wheels so subsequent move commands work - enqueue(String(CMD_SET_MOTOR) + " LWheelEnable RWheelEnable", wrapAction(callback), - PRIORITY_CRITICAL); - }, - PRIORITY_CRITICAL); - } - // Robot rejects distances outside ±10000mm — clamp to protocol limits - leftMM = constrain(leftMM, -10000, 10000); - rightMM = constrain(rightMM, -10000, 10000); - speedMMs = constrain(speedMMs, 0, 300); - String cmd = String(CMD_SET_MOTOR) + " LWheelDist " + String(leftMM) + " RWheelDist " + String(rightMM) + - " Speed " + String(speedMMs); - return enqueue(cmd, wrapAction(callback), PRIORITY_CRITICAL); -} - -bool NeatoSerial::setMotorBrush(int rpm, std::function callback) { - if (rpm <= 0) { - return enqueue(String(CMD_SET_MOTOR) + " BrushDisable", wrapAction(callback), PRIORITY_MEDIUM); - } - // BrushEnable energizes the motor driver, then Brush + RPM starts spinning. - // Brush flag is mutually exclusive with wheel/vacuum commands per call. - return enqueue( - String(CMD_SET_MOTOR) + " BrushEnable", - [this, rpm, callback](bool ok, const String&) { - if (!ok) { - if (callback) - callback(false); - return; - } - enqueue(String(CMD_SET_MOTOR) + " Brush RPM " + String(rpm), wrapAction(callback), PRIORITY_MEDIUM); - }, - PRIORITY_MEDIUM); -} - -bool NeatoSerial::setMotorVacuum(bool on, int speedPercent, std::function callback) { - // VacuumSpeed must be combined with VacuumOn in the same call. - String cmd = String(CMD_SET_MOTOR); - cmd += on ? " VacuumOn VacuumSpeed " + String(speedPercent) : " VacuumOff"; - return enqueue(cmd, wrapAction(callback), PRIORITY_MEDIUM); -} - -bool NeatoSerial::setMotorSideBrush(bool on, int powerMw, std::function callback) { - // Side brush (Botvac D3-D7 only) uses open-loop power control in milliwatts. - // Two-layer control: SideBrushEnable energizes motor driver, SideBrushOn starts spinning. - // Completely independent from the main brush. - if (!on) { - return enqueue( - String(CMD_SET_MOTOR) + " SideBrushOff", - [this, callback](bool ok, const String&) { - // Best-effort disable after stopping - enqueue(String(CMD_SET_MOTOR) + " SideBrushDisable", wrapAction(callback), PRIORITY_MEDIUM); - }, - PRIORITY_MEDIUM); - } - return enqueue( - String(CMD_SET_MOTOR) + " SideBrushEnable", - [this, powerMw, callback](bool ok, const String&) { - if (!ok) { - if (callback) - callback(false); - return; - } - enqueue(String(CMD_SET_MOTOR) + " SideBrushOn SideBrushPower " + String(powerMw), wrapAction(callback), - PRIORITY_MEDIUM); - }, - PRIORITY_MEDIUM); -} - -// -- User settings ----------------------------------------------------------- - -bool NeatoSerial::setUserSetting(const String& key, const String& value, std::function callback) { - userSettingsCache.invalidate(); - String cmd = String(CMD_SET_USER_SETTINGS) + " " + key + " " + value; - return enqueue(cmd, wrapAction(callback)); -} - -bool NeatoSerial::setNavigationMode(const String& mode, std::function callback) { - String cmd = String(CMD_SET_NAVIGATION_MODE) + " " + mode; - return enqueue(cmd, wrapAction(callback)); -} - -// -- Power control ----------------------------------------------------------- - -bool NeatoSerial::powerControl(const String& action, std::function callback) { - const char *cmd; - if (action == "restart") { - cmd = CMD_SET_SYSTEM_MODE_POWER_CYCLE; - } else if (action == "shutdown") { - cmd = CMD_SET_SYSTEM_MODE_SHUTDOWN; - } else { - LOG("NEATO", "powerControl: unknown action '%s'", action.c_str()); - if (callback) - callback(false); - return false; - } - - // SetSystemMode requires TestMode On first (protocol specifies 100ms delay, - // which the inter-command delay covers). Chain: TestMode On -> SetSystemMode. - invalidateState(); - const char *sysCmd = cmd; // capture for lambda - return testMode(true, [this, sysCmd, callback](bool ok) { - if (!ok) { - LOG("NEATO", "powerControl: TestMode On failed"); - if (callback) - callback(false); - return; - } - enqueue(sysCmd, wrapAction(callback), PRIORITY_HIGH); - }); -} - -// -- Clear errors ------------------------------------------------------------ - -bool NeatoSerial::clearErrors(std::function callback) { - invalidateState(); - return enqueue(CMD_SET_UI_ERROR_CLEAR_ALL, wrapAction(callback)); -} - -bool NeatoSerial::sendRaw(const String& cmd, std::function callback) { - if (cmd.isEmpty()) - return false; - return enqueue(cmd, callback); -} +#include "neato_serial.h" +#include "config.h" + +// -- Lifecycle --------------------------------------------------------------- + +// Helper: create a cache hit lambda that fires loggerCallback with cached=true. +// Captures `this` so it reads loggerCallback at call time (works before setLogger). +#define CACHE_HIT(CMD) \ + [this](unsigned long ageMs) { \ + if (loggerCallback) \ + loggerCallback(CMD, CMD_SUCCESS, 0, "", 0, 0, ageMs); \ + } + +NeatoSerial::NeatoSerial() : + LoopTask(0), versionCache( + CACHE_TTL_VERSION, [this](AsyncCache::Callback cb) { fetchVersion(cb); }, + CACHE_HIT(CMD_GET_VERSION)), + chargerCache( + CACHE_TTL_CHARGER, [this](AsyncCache::Callback cb) { fetchCharger(cb); }, + CACHE_HIT(CMD_GET_CHARGER)), + analogCache( + CACHE_TTL_SENSORS, [this](AsyncCache::Callback cb) { fetchBatteryAnalog(cb); }, + CACHE_HIT(CMD_GET_ANALOG_SENSORS)), + warrantyCache( + CACHE_TTL_VERSION, [this](AsyncCache::Callback cb) { fetchBatteryWarranty(cb); }, + CACHE_HIT(CMD_GET_WARRANTY)), + digitalCache( + CACHE_TTL_SENSORS, [this](AsyncCache::Callback cb) { fetchDigitalSensors(cb); }, + CACHE_HIT(CMD_GET_DIGITAL_SENSORS)), + motorCache( + CACHE_TTL_SENSORS, [this](AsyncCache::Callback cb) { fetchMotors(cb); }, + CACHE_HIT(CMD_GET_MOTORS)), + stateCache( + CACHE_TTL_STATE, [this](AsyncCache::Callback cb) { fetchState(cb); }, CACHE_HIT(CMD_GET_STATE)), + errCache( + CACHE_TTL_STATE, [this](AsyncCache::Callback cb) { fetchErr(cb); }, CACHE_HIT(CMD_GET_ERR)), + ldsCache( + CACHE_TTL_LDS, [this](AsyncCache::Callback cb) { fetchLdsScan(cb); }, + CACHE_HIT(CMD_GET_LDS_SCAN)), + robotPosRawCache( + CACHE_TTL_SENSORS, + [this](AsyncCache::Callback cb) { fetchRobotPos(CMD_GET_ROBOT_POS_RAW, cb); }, + CACHE_HIT(CMD_GET_ROBOT_POS_RAW)), + robotPosSmoothCache( + CACHE_TTL_SENSORS, + [this](AsyncCache::Callback cb) { fetchRobotPos(CMD_GET_ROBOT_POS_SMOOTH, cb); }, + CACHE_HIT(CMD_GET_ROBOT_POS_SMOOTH)), + userSettingsCache( + CACHE_TTL_VERSION, [this](AsyncCache::Callback cb) { fetchUserSettings(cb); }, + CACHE_HIT(CMD_GET_USER_SETTINGS)) { + TaskRegistry::add(this); +} + +#undef CACHE_HIT + +void NeatoSerial::begin(int txPin, int rxPin) { + uart.setRxBufferSize(NEATO_UART_RX_BUFFER); + uart.begin(NEATO_BAUD_RATE, SERIAL_8N1, rxPin, txPin); + LOG("NEATO", "UART initialized (TX=GPIO%d, RX=GPIO%d, baud=%d)", txPin, rxPin, NEATO_BAUD_RATE); +} + +void NeatoSerial::initSKey() { + sKeyPending = true; + versionCache.invalidate(); // Force a fresh fetch (don't serve a stale failure) + getVersion([this](bool ok, const VersionData& v) { + if (!ok || v.serialNumber.length() == 0) { + LOG("NEATO", "SKey init failed — GetVersion returned no serial, retrying in %lu ms", sKeyRetryDelay); + sKeyRetryAt = millis() + sKeyRetryDelay; + sKeyRetryDelay = (sKeyRetryDelay * 2 < SKEY_RETRY_MAX_MS) ? sKeyRetryDelay * 2 : SKEY_RETRY_MAX_MS; + return; + } + sKeyPending = false; + robotModelName = v.modelName; + LOG("NEATO", "Model: %s (supported=%s)", robotModelName.c_str(), + isSupportedModel(robotModelName) ? "yes" : "no"); + sKey = computeSKey(v.serialNumber); + if (sKey.length() > 0) { + LOG("NEATO", "SKey computed (%d chars) from serial %s", sKey.length(), v.serialNumber.c_str()); + } else { + LOG("NEATO", "SKey computation failed for serial: %s", v.serialNumber.c_str()); + } + }); +} + +String NeatoSerial::buildSetEvent(const char *event) const { + return String(CMD_SET_EVENT_PREFIX) + event + CMD_SET_EVENT_SKEY + sKey; +} + +void NeatoSerial::tick() { + // Drive initSKey lifecycle: first attempt + retries on failure. + // Runs inside tick() (not setup()) so the UART state machine is already + // processing the queue — avoids the race where GetVersion was enqueued + // in setup() but tick() hadn't started yet. + if (sKeyPending && millis() >= sKeyRetryAt) { + sKeyRetryAt = ULONG_MAX; // Prevent re-entry while fetch is in flight + initSKey(); + } + + switch (state) { + case QUEUE_IDLE: + if (!queue.empty()) { + dequeueNext(); + } + break; + + case QUEUE_SENDING: + sendCurrentCommand(); + break; + + case QUEUE_WAITING_RESPONSE: { + // Read all available bytes + while (uart.available()) { + char c = static_cast(uart.read()); + if (c == NEATO_RESPONSE_TERMINATOR) { + unsigned long elapsed = millis() - commandSentAt; + LOG("NEATO", "RX: %u bytes in %lu ms", responseBuffer.length(), elapsed); + + // Validate: response must echo the sent command on the first line. + // If it doesn't, the UART stream is desynced (we got a response + // meant for a different command). Flush and fail this request so + // the next command starts clean. + if (!validateResponseEcho(responseBuffer)) { + LOG("NEATO", "DESYNC: sent '%s' but response echoed a different command, flushing", + currentCommand.c_str()); + flushUartRx(); + completeCommand(CMD_SERIAL_ERROR, responseBuffer); + return; + } + + // Detect "Unknown Cmd" — robot doesn't support this command + if (responseBuffer.indexOf("Unknown Cmd") >= 0) { + LOG("NEATO", "Unsupported: %s", currentCommand.c_str()); + completeCommand(CMD_UNSUPPORTED, responseBuffer); + return; + } + + completeCommand(CMD_SUCCESS, responseBuffer); + return; + } + responseBuffer += c; + } + // Check timeout + if (millis() - commandSentAt >= NEATO_CMD_TIMEOUT_MS) { + LOG("NEATO", "Timeout: %s (%lu ms, partial: %u bytes)", currentCommand.c_str(), + (unsigned long) NEATO_CMD_TIMEOUT_MS, responseBuffer.length()); + // Log partial response on timeout (useful for debugging serial issues). + // Flush UART to prevent stale bytes from leaking into the next command. + flushUartRx(); + completeCommand(CMD_TIMEOUT, responseBuffer); + } + break; + } + + case QUEUE_INTER_DELAY: + if (millis() - delayStartedAt >= NEATO_INTER_CMD_DELAY_MS) { + state = QUEUE_IDLE; + } + break; + } +} + +// -- Queue management -------------------------------------------------------- + +bool NeatoSerial::enqueue(const String& command, std::function callback, + CommandPriority priority) { + if (static_cast(queue.size()) >= NEATO_QUEUE_MAX_SIZE) { + LOG("NEATO", "Queue full, rejecting: %s", command.c_str()); + if (loggerCallback) + loggerCallback(command, CMD_QUEUE_FULL, 0, "", static_cast(queue.size()), 0, 0); + if (callback) + callback(false, ""); + return false; + } + // Lower number = higher priority. Keep FIFO order within same priority. + auto it = queue.begin(); + for (; it != queue.end(); ++it) { + if (it->priority > priority) + break; + } + queue.insert(it, {command, static_cast(priority), callback}); + return true; +} + +std::function NeatoSerial::wrapAction(std::function callback) { + if (!callback) + return nullptr; + return [callback](bool ok, const String&) { callback(ok); }; +} + +void NeatoSerial::dequeueNext() { + if (queue.empty()) + return; + + // Capture queue depth before dequeue (for logging) + queueDepthAtStart = static_cast(queue.size()); + + CommandEntry entry = queue.front(); + queue.erase(queue.begin()); + + currentCommand = entry.command; + currentCallback = entry.callback; + responseBuffer = ""; + + state = QUEUE_SENDING; +} + +void NeatoSerial::flushUartRx() { + int flushed = 0; + while (uart.available()) { + uart.read(); + flushed++; + } + if (flushed > 0) { + LOG("NEATO", "Flushed %d stale bytes from UART RX", flushed); + } +} + +void NeatoSerial::sendCurrentCommand() { + // Drain any stale bytes from a previous response that may have arrived late + flushUartRx(); + + LOG("NEATO", "TX: %s", currentCommand.c_str()); + uart.print(currentCommand + "\n"); + commandSentAt = millis(); + state = QUEUE_WAITING_RESPONSE; +} + +bool NeatoSerial::validateResponseEcho(const String& response) const { + // The Neato echoes the command on the first line of the response, + // e.g. "GetCharger\r\nLabel,Value\r\n...". Extract the first word of the + // sent command (before any space/flag) and check the response starts with it. + // Some commands echo only the name ("Clean" for "Clean House"), while others + // echo the full command with arguments ("TestMode On" for "TestMode On"). + // Using startsWith handles both cases. + String expectedEcho = currentCommand; + int spacePos = expectedEcho.indexOf(' '); + if (spacePos > 0) + expectedEcho = expectedEcho.substring(0, spacePos); + + // Find the first line in the response (up to \r or \n) + String firstLine = response; + int crPos = firstLine.indexOf('\r'); + int lfPos = firstLine.indexOf('\n'); + int lineEnd = -1; + if (crPos >= 0 && lfPos >= 0) + lineEnd = (crPos < lfPos) ? crPos : lfPos; + else if (crPos >= 0) + lineEnd = crPos; + else if (lfPos >= 0) + lineEnd = lfPos; + if (lineEnd >= 0) + firstLine = firstLine.substring(0, lineEnd); + + // Check that the echo line starts with the expected command name (case-insensitive). + firstLine.trim(); + expectedEcho.trim(); + firstLine.toLowerCase(); + expectedEcho.toLowerCase(); + return firstLine.startsWith(expectedEcho); +} + +void NeatoSerial::completeCommand(CommandStatus status, const String& response) { + unsigned long elapsed = millis() - commandSentAt; + String cmd = currentCommand; + auto cb = currentCallback; + int qDepth = queueDepthAtStart; + String resp = response; // Copy before clearing responseBuffer (response is a ref to it) + size_t respBytes = resp.length(); + + currentCommand = ""; + currentCallback = nullptr; + responseBuffer = ""; + queueDepthAtStart = 0; + + // Start inter-command delay before next command + delayStartedAt = millis(); + state = QUEUE_INTER_DELAY; + + // Fire logger hook before user callback with enhanced metadata + if (loggerCallback) + loggerCallback(cmd, status, elapsed, resp, qDepth, respBytes, 0); + + // User callback still gets simple bool for backward compatibility + bool success = (status == CMD_SUCCESS); + if (cb) + cb(success, resp); +} + +// -- Cached sensor query methods (public API) -------------------------------- +// These delegate to AsyncCache, which handles TTL, dedup, and coalescing. + +void NeatoSerial::getVersion(std::function callback) { + versionCache.get(callback); +} + +void NeatoSerial::getCharger(std::function callback) { + chargerCache.get(callback); +} + +void NeatoSerial::getBatteryAnalog(std::function callback) { + analogCache.get(callback); +} + +void NeatoSerial::getBatteryWarranty(std::function callback) { + warrantyCache.get(callback); +} + +void NeatoSerial::getUserSettings(std::function callback) { + userSettingsCache.get(callback); +} + +void NeatoSerial::getDigitalSensors(std::function callback) { + getDigitalSensors(callback, PRIORITY_NORMAL); +} + +void NeatoSerial::getDigitalSensors(std::function callback, + CommandPriority priority) { + // Normal priority goes through cache; elevated priority bypasses it + // to guarantee commands are enqueued at the requested priority. + if (priority == PRIORITY_NORMAL) { + digitalCache.get(callback); + } else { + fetchDigitalSensors(callback, priority); + } +} + +void NeatoSerial::getMotors(std::function callback) { + getMotors(callback, PRIORITY_NORMAL); +} + +void NeatoSerial::getMotors(std::function callback, CommandPriority priority) { + // Normal priority goes through cache; elevated priority bypasses it + // to guarantee commands are enqueued at the requested priority. + if (priority == PRIORITY_NORMAL) { + motorCache.get(callback); + } else { + fetchMotors(callback, priority); + } +} + +void NeatoSerial::getState(std::function callback) { + // During manual clean, the robot reports UIMGR_STATE_TESTMODE. + // Override to UIMGR_STATE_MANUALCLEANING so callers (frontend, dashboard) + // see the correct pseudo-state without needing to know about TestMode internals. + if (manualCleanActive) { + stateCache.get([callback](bool ok, const RobotState& data) { + if (!ok || !callback) { + if (callback) + callback(ok, data); + return; + } + RobotState patched = data; + patched.uiState = "UIMGR_STATE_MANUALCLEANING"; + callback(true, patched); + }); + return; + } + stateCache.get(callback); +} + +void NeatoSerial::getErr(std::function callback) { + errCache.get(callback); +} + +void NeatoSerial::getErrClear(std::function callback) { + // getErrClear is never cached — it clears the error and always needs a fresh fetch + fetchErrClear(callback); +} + +void NeatoSerial::getLdsScan(std::function callback) { + ldsCache.get(callback); +} + +void NeatoSerial::getRobotPos(bool smooth, std::function callback) { + (smooth ? robotPosSmoothCache : robotPosRawCache).get(callback); +} + +// -- Raw fetch methods (enqueue serial command, parse response) --------------- + +void NeatoSerial::fetchVersion(std::function callback) { + enqueue(CMD_GET_VERSION, [callback](bool ok, const String& raw) { + VersionData data; + if (ok) + ok = parseVersionData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchCharger(std::function callback) { + enqueue(CMD_GET_CHARGER, [callback](bool ok, const String& raw) { + ChargerData data; + if (ok) + ok = parseChargerData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchBatteryAnalog(std::function callback) { + enqueue(CMD_GET_ANALOG_SENSORS, [callback](bool ok, const String& raw) { + BatteryAnalogData data; + if (ok) + ok = parseBatteryAnalogData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchBatteryWarranty(std::function callback) { + enqueue(CMD_GET_WARRANTY, [callback](bool ok, const String& raw) { + BatteryWarrantyData data; + if (ok) + ok = parseBatteryWarrantyData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchDigitalSensors(std::function callback, + CommandPriority priority) { + enqueue( + CMD_GET_DIGITAL_SENSORS, + [callback](bool ok, const String& raw) { + DigitalSensorData data; + if (ok) + ok = parseDigitalSensorData(raw, data); + if (callback) + callback(ok, data); + }, + priority); +} + +void NeatoSerial::fetchMotors(std::function callback, CommandPriority priority) { + enqueue( + CMD_GET_MOTORS, + [callback](bool ok, const String& raw) { + MotorData data; + if (ok) + ok = parseMotorData(raw, data); + if (callback) + callback(ok, data); + }, + priority); +} + +void NeatoSerial::fetchState(std::function callback) { + enqueue(CMD_GET_STATE, [callback](bool ok, const String& raw) { + RobotState data; + if (ok) + ok = parseRobotState(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchErr(std::function callback) { + enqueue(CMD_GET_ERR, [callback](bool ok, const String& raw) { + ErrorData data; + if (ok) + ok = parseErrorData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchErrClear(std::function callback) { + enqueue(CMD_GET_ERR_CLEAR, [callback](bool ok, const String& raw) { + ErrorData data; + if (ok) + ok = parseErrorData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchLdsScan(std::function callback) { + enqueue(CMD_GET_LDS_SCAN, [callback](bool ok, const String& raw) { + LdsScanData data; + if (ok) + ok = parseLdsScanData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchRobotPos(const char *cmd, std::function callback) { + enqueue(cmd, [callback](bool ok, const String& raw) { + RobotPosData data; + if (ok) + ok = parseRobotPosData(raw, data); + if (callback) + callback(ok, data); + }); +} + +void NeatoSerial::fetchUserSettings(std::function callback) { + enqueue(CMD_GET_USER_SETTINGS, [callback](bool ok, const String& raw) { + UserSettingsData data; + if (ok) + ok = parseUserSettingsData(raw, data); + if (callback) + callback(ok, data); + }); +} + +// -- Cache invalidation ------------------------------------------------------ + +void NeatoSerial::invalidateState() { + stateCache.invalidate(); + errCache.invalidate(); +} + +void NeatoSerial::invalidateAll() { + versionCache.invalidate(); + chargerCache.invalidate(); + analogCache.invalidate(); + warrantyCache.invalidate(); + digitalCache.invalidate(); + motorCache.invalidate(); + stateCache.invalidate(); + errCache.invalidate(); + ldsCache.invalidate(); + robotPosRawCache.invalidate(); + robotPosSmoothCache.invalidate(); + userSettingsCache.invalidate(); +} + +// -- Action command convenience methods -------------------------------------- + +bool NeatoSerial::clean(const String& action, std::function callback) { + // All cleaning control uses SetEvent — the authenticated event API that D3-D7 + // robots use for their cloud/app protocol. This correctly transitions the UI + // state machine and preserves map/localization during pause/resume. + // + // SKey must be computed at boot via initSKey(). If missing, commands will fail + // gracefully (callback with false). + + if (!hasSKey()) { + LOG("NEATO", "clean(%s) failed — SKey not available", action.c_str()); + if (callback) + callback(false); + return false; + } + + if (action == "dock") { + invalidateState(); + return enqueue(buildSetEvent(EVT_SEND_TO_BASE), wrapAction(callback), PRIORITY_HIGH); + } + + if (action == "pause") { + invalidateState(); + return enqueue(buildSetEvent(EVT_PAUSE), wrapAction(callback), PRIORITY_HIGH); + } + + if (action == "stop") { + invalidateState(); + return enqueue(buildSetEvent(EVT_STOP), wrapAction(callback), PRIORITY_HIGH); + } + + // Type-aware resume: "house" only resumes a paused house clean, "spot" + // only resumes a paused spot clean. Prevents HA "start" from resuming + // a previously paused spot clean when the user expects a new house clean. + if (action == "house") { + bool isPausedHouse = + stateCache.hasCached() && stateCache.getCached().uiState.indexOf("HOUSECLEANINGPAUSED") >= 0; + if (isPausedHouse) { + invalidateState(); + return enqueue(buildSetEvent(EVT_RESUME), wrapAction(callback), PRIORITY_HIGH); + } + invalidateState(); + if (cleanStartCallback) + cleanStartCallback(); + // Send navigation mode first (fire-and-forget); house clean proceeds regardless. + if (navModeGetter) { + String mode = navModeGetter(); + if (mode.length() > 0 && mode != "Normal") { + String navCmd = String(CMD_SET_NAVIGATION_MODE) + " " + mode; + enqueue(navCmd, nullptr, PRIORITY_HIGH); + } + } + return enqueue(buildSetEvent(EVT_START_HOUSE), wrapAction(callback), PRIORITY_HIGH); + } + + if (action == "spot") { + bool isPausedSpot = stateCache.hasCached() && stateCache.getCached().uiState.indexOf("SPOTCLEANINGPAUSED") >= 0; + if (isPausedSpot) { + invalidateState(); + return enqueue(buildSetEvent(EVT_RESUME), wrapAction(callback), PRIORITY_HIGH); + } + invalidateState(); + if (cleanStartCallback) + cleanStartCallback(); + return enqueue(buildSetEvent(EVT_START_SPOT), wrapAction(callback), PRIORITY_HIGH); + } + + // Generic action — resume whatever is paused, or start house clean + bool isPaused = stateCache.hasCached() && stateCache.getCached().uiState.indexOf("CLEANINGPAUSED") >= 0; + if (isPaused) { + invalidateState(); + return enqueue(buildSetEvent(EVT_RESUME), wrapAction(callback), PRIORITY_HIGH); + } + + // New clean from idle — fall through to house clean + invalidateState(); + if (cleanStartCallback) + cleanStartCallback(); + // Send navigation mode first (fire-and-forget); house clean proceeds regardless. + if (navModeGetter) { + String mode = navModeGetter(); + if (mode.length() > 0 && mode != "Normal") { + String navCmd = String(CMD_SET_NAVIGATION_MODE) + " " + mode; + enqueue(navCmd, nullptr, PRIORITY_HIGH); + } + } + return enqueue(buildSetEvent(EVT_START_HOUSE), wrapAction(callback), PRIORITY_HIGH); +} + +bool NeatoSerial::testMode(bool enable, std::function callback) { + const char *cmd = enable ? CMD_TEST_MODE_ON : CMD_TEST_MODE_OFF; + invalidateState(); + // HIGH priority: TestMode is a prerequisite for motor commands — must execute + // before any CRITICAL/MEDIUM motor commands that may already be queued. + return enqueue(cmd, wrapAction(callback), PRIORITY_HIGH); +} + +bool NeatoSerial::playSound(SoundId soundId, std::function callback) { + String cmd = String(CMD_PLAY_SOUND) + " SoundID " + String(static_cast(soundId)); + return enqueue(cmd, wrapAction(callback)); +} + +bool NeatoSerial::setLdsRotation(bool on, std::function callback) { + const char *cmd = on ? CMD_SET_LDS_ROTATION_ON : CMD_SET_LDS_ROTATION_OFF; + // HIGH priority: LDS rotation is a setup/teardown command that must execute + // before motor commands during manual clean enable/disable sequences. + return enqueue(cmd, wrapAction(callback), PRIORITY_HIGH); +} + +bool NeatoSerial::setMotorWheels(int leftMM, int rightMM, int speedMMs, std::function callback) { + // SetMotor 0 0 0 bug: zero values are ignored, robot coasts for ~1s. + // Workaround: disable wheels for immediate stop, then re-enable for next command. + if (leftMM == 0 && rightMM == 0) { + return enqueue( + String(CMD_SET_MOTOR) + " LWheelDisable RWheelDisable", + [this, callback](bool ok, const String&) { + // Re-enable wheels so subsequent move commands work + enqueue(String(CMD_SET_MOTOR) + " LWheelEnable RWheelEnable", wrapAction(callback), + PRIORITY_CRITICAL); + }, + PRIORITY_CRITICAL); + } + // Robot rejects distances outside ±10000mm — clamp to protocol limits + leftMM = constrain(leftMM, -10000, 10000); + rightMM = constrain(rightMM, -10000, 10000); + speedMMs = constrain(speedMMs, 0, 300); + String cmd = String(CMD_SET_MOTOR) + " LWheelDist " + String(leftMM) + " RWheelDist " + String(rightMM) + + " Speed " + String(speedMMs); + return enqueue(cmd, wrapAction(callback), PRIORITY_CRITICAL); +} + +bool NeatoSerial::setMotorBrush(int rpm, std::function callback) { + if (rpm <= 0) { + return enqueue(String(CMD_SET_MOTOR) + " BrushDisable", wrapAction(callback), PRIORITY_MEDIUM); + } + // BrushEnable energizes the motor driver, then Brush + RPM starts spinning. + // Brush flag is mutually exclusive with wheel/vacuum commands per call. + return enqueue( + String(CMD_SET_MOTOR) + " BrushEnable", + [this, rpm, callback](bool ok, const String&) { + if (!ok) { + if (callback) + callback(false); + return; + } + enqueue(String(CMD_SET_MOTOR) + " Brush RPM " + String(rpm), wrapAction(callback), PRIORITY_MEDIUM); + }, + PRIORITY_MEDIUM); +} + +bool NeatoSerial::setMotorVacuum(bool on, int speedPercent, std::function callback) { + // VacuumSpeed must be combined with VacuumOn in the same call. + String cmd = String(CMD_SET_MOTOR); + cmd += on ? " VacuumOn VacuumSpeed " + String(speedPercent) : " VacuumOff"; + return enqueue(cmd, wrapAction(callback), PRIORITY_MEDIUM); +} + +bool NeatoSerial::setMotorSideBrush(bool on, int powerMw, std::function callback) { + // Side brush (Botvac D3-D7 only) uses open-loop power control in milliwatts. + // Two-layer control: SideBrushEnable energizes motor driver, SideBrushOn starts spinning. + // Completely independent from the main brush. + if (!on) { + return enqueue( + String(CMD_SET_MOTOR) + " SideBrushOff", + [this, callback](bool ok, const String&) { + // Best-effort disable after stopping + enqueue(String(CMD_SET_MOTOR) + " SideBrushDisable", wrapAction(callback), PRIORITY_MEDIUM); + }, + PRIORITY_MEDIUM); + } + return enqueue( + String(CMD_SET_MOTOR) + " SideBrushEnable", + [this, powerMw, callback](bool ok, const String&) { + if (!ok) { + if (callback) + callback(false); + return; + } + enqueue(String(CMD_SET_MOTOR) + " SideBrushOn SideBrushPower " + String(powerMw), wrapAction(callback), + PRIORITY_MEDIUM); + }, + PRIORITY_MEDIUM); +} + +// -- User settings ----------------------------------------------------------- + +bool NeatoSerial::setUserSetting(const String& key, const String& value, std::function callback) { + userSettingsCache.invalidate(); + String cmd = String(CMD_SET_USER_SETTINGS) + " " + key + " " + value; + return enqueue(cmd, wrapAction(callback)); +} + +bool NeatoSerial::setNavigationMode(const String& mode, std::function callback) { + String cmd = String(CMD_SET_NAVIGATION_MODE) + " " + mode; + return enqueue(cmd, wrapAction(callback)); +} + +// -- Power control ----------------------------------------------------------- + +bool NeatoSerial::powerControl(const String& action, std::function callback) { + const char *cmd; + if (action == "restart") { + cmd = CMD_SET_SYSTEM_MODE_POWER_CYCLE; + } else if (action == "shutdown") { + cmd = CMD_SET_SYSTEM_MODE_SHUTDOWN; + } else { + LOG("NEATO", "powerControl: unknown action '%s'", action.c_str()); + if (callback) + callback(false); + return false; + } + + // SetSystemMode requires TestMode On first (protocol specifies 100ms delay, + // which the inter-command delay covers). Chain: TestMode On -> SetSystemMode. + invalidateState(); + const char *sysCmd = cmd; // capture for lambda + return testMode(true, [this, sysCmd, callback](bool ok) { + if (!ok) { + LOG("NEATO", "powerControl: TestMode On failed"); + if (callback) + callback(false); + return; + } + enqueue(sysCmd, wrapAction(callback), PRIORITY_HIGH); + }); +} + +// -- Clear errors ------------------------------------------------------------ + +bool NeatoSerial::clearErrors(std::function callback) { + invalidateState(); + return enqueue(CMD_SET_UI_ERROR_CLEAR_ALL, wrapAction(callback)); +} + +bool NeatoSerial::newBattery(std::function callback) { + invalidateState(); + return testMode(true, [this, callback](bool ok) { + if (!ok) { + if (callback) + callback(false); + return; + } + + enqueue( + CMD_NEW_BATTERY, + [this, callback](bool okCmd, const String&) { + if (!okCmd) { + // Best-effort return robot to normal mode even if NewBattery failed + testMode(false, nullptr); + if (callback) + callback(false); + return; + } + testMode(false, callback); + }, + PRIORITY_HIGH); + }); +} + +bool NeatoSerial::sendRaw(const String& cmd, std::function callback) { + if (cmd.isEmpty()) + return false; + return enqueue(cmd, callback); +} diff --git a/firmware/src/neato_serial.h b/firmware/src/neato_serial.h index 31546c4..2d6346f 100644 --- a/firmware/src/neato_serial.h +++ b/firmware/src/neato_serial.h @@ -49,6 +49,8 @@ class NeatoSerial : public LoopTask { void getVersion(std::function callback); void getCharger(std::function callback); + void getBatteryAnalog(std::function callback); + void getBatteryWarranty(std::function callback); void getUserSettings(std::function callback); void getDigitalSensors(std::function callback); void getDigitalSensors(std::function callback, CommandPriority priority); @@ -85,6 +87,7 @@ class NeatoSerial : public LoopTask { // Clear all UI errors/warnings via "SetUIError clearall". bool clearErrors(std::function callback = nullptr); + bool newBattery(std::function callback = nullptr); // -- Raw command (temporary debug endpoint) -------------------------------- bool sendRaw(const String& cmd, std::function callback); @@ -188,6 +191,8 @@ class NeatoSerial : public LoopTask { AsyncCache versionCache; AsyncCache chargerCache; + AsyncCache analogCache; + AsyncCache warrantyCache; AsyncCache digitalCache; AsyncCache motorCache; AsyncCache stateCache; @@ -200,6 +205,8 @@ class NeatoSerial : public LoopTask { // Raw (uncached) fetch methods — enqueue the command and parse response void fetchVersion(std::function callback); void fetchCharger(std::function callback); + void fetchBatteryAnalog(std::function callback); + void fetchBatteryWarranty(std::function callback); void fetchDigitalSensors(std::function callback, CommandPriority priority = PRIORITY_NORMAL); void fetchMotors(std::function callback, CommandPriority priority = PRIORITY_NORMAL); diff --git a/firmware/src/notification_manager.cpp b/firmware/src/notification_manager.cpp index f30c180..0931826 100644 --- a/firmware/src/notification_manager.cpp +++ b/firmware/src/notification_manager.cpp @@ -1,236 +1,236 @@ -#include "notification_manager.h" -#include "cleaning_history.h" -#include "neato_serial.h" -#include "settings_manager.h" -#include "data_logger.h" -#include -#include -#include - -#define NTFY_DEFAULT_HOST "ntfy.sh" -#define NTFY_CONNECT_TIMEOUT_MS 3000 - -// Max wall-clock to wait for CleaningHistory::stopCollection's async charger -// fetch to finalize stats before sending the "done" notification anyway. -#define NTFY_DONE_PENDING_TIMEOUT_MS 5000 - -NotificationManager::NotificationManager(NeatoSerial& neato, SettingsManager& settings, DataLogger& logger, - CleaningHistory& history) : - LoopTask(NOTIF_INTERVAL_IDLE_MS), neato(neato), settings(settings), dataLogger(logger), history(history) { - TaskRegistry::add(this); -} - -void NotificationManager::begin() { - LOG("NOTIF", "Notification manager initialized"); -} - -void NotificationManager::tick() { - // Drain a pending "done" notification independently of the fetch path — - // it's waiting on CleaningHistory's async stop, not our own state fetch. - if (donePending) - flushPendingDone(); - - if (fetchPending) - return; - - // Skip if notifications disabled, no topic configured, or WiFi not connected - const Settings& s = settings.get(); - if (!s.ntfyEnabled || s.ntfyTopic.isEmpty() || WiFi.status() != WL_CONNECTED) - return; - - checkTransitions(); -} - -void NotificationManager::checkTransitions() { - fetchPending = true; - - // Fetch state first, then error — both return from cache (zero serial cost within TTL) - neato.getState([this](bool stateOk, const RobotState& state) { - neato.getErr([this, stateOk, state](bool errOk, const ErrorData& err) { - fetchPending = false; - - const Settings& cfg = settings.get(); - const String& topic = cfg.ntfyTopic; - const String& hostname = cfg.hostname; - - if (stateOk) { - const String& ui = state.uiState; - const String& rs = state.robotState; - - // Detect transitions - if (!prevUiState.isEmpty()) { - bool wasCleaning = prevUiState.indexOf("CLEANINGRUNNING") >= 0; - bool wasDocking = prevUiState.indexOf("DOCKING") >= 0; - bool isCleaningRunning = ui.indexOf("CLEANINGRUNNING") >= 0; - bool isDocking = ui.indexOf("DOCKING") >= 0; - bool isSuspended = ui.indexOf("CLEANINGSUSPENDED") >= 0; - bool isIdle = ui == "UIMGR_STATE_IDLE" || ui == "UIMGR_STATE_STANDBY"; - - // Track cleaning context when entering docking - if (wasCleaning && isDocking) { - wasCleaningBeforeDock = true; - } - - // Mid-clean recharge: robot state ST_M1_Charging_Cleaning means - // the robot docked to recharge and will resume cleaning afterwards. - // The UI state transitions DOCKING -> CLEANINGSUSPENDED once on the dock. - bool isRecharging = rs.indexOf("Charging_Cleaning") >= 0; - - // Fresh start: idle -> CLEANINGRUNNING (excludes resume from - // pause/suspended and resume after mid-clean recharge dock). - bool prevInCleaningContext = prevUiState.indexOf("CLEANING") >= 0 || - prevUiState.indexOf("DOCKING") >= 0; - if (isCleaningRunning && !prevInCleaningContext && cfg.ntfyOnStart) { - sendNotification(topic, "arrow_forward", hostname + ": Cleaning started"); - } - - if (isDocking && !wasDocking && isRecharging && cfg.ntfyOnDocking) { - // Recharge dock — robot will resume cleaning after charging - sendNotification(topic, "electric_plug", hostname + ": Returning to base to recharge"); - } - - // Cleaning completed: cleaning/docking -> idle, but NOT if it's a recharge. - // Also handle suspended -> idle (user stops clean while recharging). - bool dockingDone = wasDocking && wasCleaningBeforeDock && !isRecharging; - bool suspendedDone = (prevUiState.indexOf("CLEANINGSUSPENDED") >= 0) && wasCleaningBeforeDock; - if ((wasCleaning || dockingDone || suspendedDone) && isIdle && cfg.ntfyOnDone && !donePending) { - // Defer the send: CleaningHistory::stopCollection finalizes stats - // inside an async getCharger callback, so reading getLastCleanStats() - // here can race and pull stale data from the prior session. Capture - // the current sessionId; flushPendingDone() fires once it increments - // (or after NTFY_DONE_PENDING_TIMEOUT_MS). - donePending = true; - doneTriggerSessionId = history.getLastCleanStats().sessionId; - donePendingSinceMs = millis(); - doneHostname = hostname; - doneTopic = topic; - } - - // Clear tracking flag when leaving docking — but preserve it - // through DOCKING -> CLEANINGSUSPENDED (mid-clean recharge) - if (wasDocking && !isDocking && !isSuspended) { - wasCleaningBeforeDock = false; - } - } - - // Update adaptive interval based on current state - bool active = isActiveState(ui); - setInterval(active ? NOTIF_INTERVAL_ACTIVE_MS : NOTIF_INTERVAL_IDLE_MS); - prevUiState = ui; - prevRobotState = rs; - } - - if (errOk) { - // New error or alert detected: was no error -> now has error, or code changed - if (err.hasError && (!prevHasError || err.errorCode != prevErrorCode)) { - bool isAlert = (err.kind == "warning"); // UI_ALERT_* (201-242) - bool allowed = isAlert ? cfg.ntfyOnAlert : cfg.ntfyOnError; - if (allowed) { - String tag = isAlert ? "information_source" : "warning"; - sendNotification(topic, tag, hostname + ": " + err.displayMessage); - } - } - prevHasError = err.hasError; - prevErrorCode = err.errorCode; - } - }); - }); -} - -bool NotificationManager::isActiveState(const String& uiState) { - return uiState.indexOf("CLEANINGRUNNING") >= 0 || uiState.indexOf("CLEANINGPAUSED") >= 0 || - uiState.indexOf("CLEANINGSUSPENDED") >= 0 || uiState.indexOf("DOCKING") >= 0; -} - -void NotificationManager::flushPendingDone() { - const LastCleanStats& stats = history.getLastCleanStats(); - bool finalized = stats.sessionId != doneTriggerSessionId; - bool timedOut = (millis() - donePendingSinceMs) >= NTFY_DONE_PENDING_TIMEOUT_MS; - if (!finalized && !timedOut) - return; - - // If stopCollection finalized after we triggered, sessionId moved and stats - // are fresh; if we timed out, fire bare without stats rather than stale. - sendDoneNotification(doneTopic, doneHostname, finalized && stats.valid); - donePending = false; -} - -void NotificationManager::sendDoneNotification(const String& topic, const String& hostname, bool withStats) { - String msg = hostname + ": Cleaning done"; - if (withStats) { - const LastCleanStats& stats = history.getLastCleanStats(); - long mins = stats.durationSec / 60; - msg += "\n" + String(mins) + "min"; - msg += " | " + String(stats.areaCoveredM2, 1) + "m2"; - msg += " | " + String(stats.distanceM, 0) + "m"; - if (stats.batteryStart >= 0 && stats.batteryEnd >= 0) { - msg += " | " + String(stats.batteryStart) + "% -> " + String(stats.batteryEnd) + "%"; - } - } - sendNotification(topic, "white_check_mark", msg); -} - -void NotificationManager::sendNotification(const String& topic, const String& tags, const String& message) { - LOG("NOTIF", "Sending: [%s] %s", tags.c_str(), message.c_str()); - - const Settings& cfg = settings.get(); - String host = cfg.ntfyServer.isEmpty() ? NTFY_DEFAULT_HOST : cfg.ntfyServer; - bool useHttps = !cfg.ntfyServer.isEmpty(); // Custom servers use HTTPS; ntfy.sh uses plain HTTP - - WiFiClientSecure secureClient; - WiFiClient plainClient; - Client* client; - - if (useHttps) { - secureClient.setInsecure(); // Skip cert validation — self-hosted server - secureClient.setTimeout(NTFY_CONNECT_TIMEOUT_MS); - if (!secureClient.connect(host.c_str(), 443)) { - LOG("NOTIF", "TLS connect to %s:443 failed", host.c_str()); - dataLogger.logNotification("notif_send_fail", message, false); - return; - } - client = &secureClient; - } else { - plainClient.setTimeout(NTFY_CONNECT_TIMEOUT_MS); - if (!plainClient.connect(host.c_str(), 80)) { - LOG("NOTIF", "Connect to %s:80 failed", host.c_str()); - dataLogger.logNotification("notif_send_fail", message, false); - return; - } - client = &plainClient; - } - - // Build HTTP POST request - client->print("POST /" + topic + " HTTP/1.1\r\n"); - client->print("Host: " + host + "\r\n"); - client->print("Content-Type: text/plain\r\n"); - client->print("Tags: " + tags + "\r\n"); - client->print("Content-Length: " + String(message.length()) + "\r\n"); - if (!cfg.ntfyToken.isEmpty()) { - client->print("Authorization: Bearer " + cfg.ntfyToken + "\r\n"); - } - client->print("Connection: close\r\n"); - client->print("\r\n"); - client->print(message); - - // Read just the HTTP status line (e.g. "HTTP/1.1 200 OK\r\n") - bool ok = false; - String statusLine = client->readStringUntil('\n'); - if (statusLine.length() > 0) { - int spaceIdx = statusLine.indexOf(' '); - if (spaceIdx > 0) { - int code = statusLine.substring(spaceIdx + 1, spaceIdx + 4).toInt(); - ok = (code >= 200 && code < 300); - LOG("NOTIF", "Response: %d %s", code, ok ? "OK" : "FAIL"); - } - } - - client->stop(); - - dataLogger.logNotification("notif_sent", message, ok); -} - -void NotificationManager::sendTestNotification(const String& topic) { - const String& hostname = settings.get().hostname; - sendNotification(topic, "bell", hostname + ": Test notification"); -} +#include "notification_manager.h" +#include "cleaning_history.h" +#include "neato_serial.h" +#include "settings_manager.h" +#include "data_logger.h" +#include +#include +#include + +#define NTFY_DEFAULT_HOST "ntfy.sh" +#define NTFY_CONNECT_TIMEOUT_MS 3000 + +// Max wall-clock to wait for CleaningHistory::stopCollection's async charger +// fetch to finalize stats before sending the "done" notification anyway. +#define NTFY_DONE_PENDING_TIMEOUT_MS 5000 + +NotificationManager::NotificationManager(NeatoSerial& neato, SettingsManager& settings, DataLogger& logger, + CleaningHistory& history) : + LoopTask(NOTIF_INTERVAL_IDLE_MS), neato(neato), settings(settings), dataLogger(logger), history(history) { + TaskRegistry::add(this); +} + +void NotificationManager::begin() { + LOG("NOTIF", "Notification manager initialized"); +} + +void NotificationManager::tick() { + // Drain a pending "done" notification independently of the fetch path — + // it's waiting on CleaningHistory's async stop, not our own state fetch. + if (donePending) + flushPendingDone(); + + if (fetchPending) + return; + + // Skip if notifications disabled, no topic configured, or WiFi not connected + const Settings& s = settings.get(); + if (!s.ntfyEnabled || s.ntfyTopic.isEmpty() || WiFi.status() != WL_CONNECTED) + return; + + checkTransitions(); +} + +void NotificationManager::checkTransitions() { + fetchPending = true; + + // Fetch state first, then error — both return from cache (zero serial cost within TTL) + neato.getState([this](bool stateOk, const RobotState& state) { + neato.getErr([this, stateOk, state](bool errOk, const ErrorData& err) { + fetchPending = false; + + const Settings& cfg = settings.get(); + const String& topic = cfg.ntfyTopic; + const String& hostname = cfg.hostname; + + if (stateOk) { + const String& ui = state.uiState; + const String& rs = state.robotState; + + // Detect transitions + if (!prevUiState.isEmpty()) { + bool wasCleaning = prevUiState.indexOf("CLEANINGRUNNING") >= 0; + bool wasDocking = prevUiState.indexOf("DOCKING") >= 0; + bool isCleaningRunning = ui.indexOf("CLEANINGRUNNING") >= 0; + bool isDocking = ui.indexOf("DOCKING") >= 0; + bool isSuspended = ui.indexOf("CLEANINGSUSPENDED") >= 0; + bool isIdle = ui == "UIMGR_STATE_IDLE" || ui == "UIMGR_STATE_STANDBY"; + + // Track cleaning context when entering docking + if (wasCleaning && isDocking) { + wasCleaningBeforeDock = true; + } + + // Mid-clean recharge: robot state ST_M1_Charging_Cleaning means + // the robot docked to recharge and will resume cleaning afterwards. + // The UI state transitions DOCKING -> CLEANINGSUSPENDED once on the dock. + bool isRecharging = rs.indexOf("Charging_Cleaning") >= 0; + + // Fresh start: idle -> CLEANINGRUNNING (excludes resume from + // pause/suspended and resume after mid-clean recharge dock). + bool prevInCleaningContext = + prevUiState.indexOf("CLEANING") >= 0 || prevUiState.indexOf("DOCKING") >= 0; + if (isCleaningRunning && !prevInCleaningContext && cfg.ntfyOnStart) { + sendNotification(topic, "arrow_forward", hostname + ": Cleaning started"); + } + + if (isDocking && !wasDocking && isRecharging && cfg.ntfyOnDocking) { + // Recharge dock — robot will resume cleaning after charging + sendNotification(topic, "electric_plug", hostname + ": Returning to base to recharge"); + } + + // Cleaning completed: cleaning/docking -> idle, but NOT if it's a recharge. + // Also handle suspended -> idle (user stops clean while recharging). + bool dockingDone = wasDocking && wasCleaningBeforeDock && !isRecharging; + bool suspendedDone = (prevUiState.indexOf("CLEANINGSUSPENDED") >= 0) && wasCleaningBeforeDock; + if ((wasCleaning || dockingDone || suspendedDone) && isIdle && cfg.ntfyOnDone && !donePending) { + // Defer the send: CleaningHistory::stopCollection finalizes stats + // inside an async getCharger callback, so reading getLastCleanStats() + // here can race and pull stale data from the prior session. Capture + // the current sessionId; flushPendingDone() fires once it increments + // (or after NTFY_DONE_PENDING_TIMEOUT_MS). + donePending = true; + doneTriggerSessionId = history.getLastCleanStats().sessionId; + donePendingSinceMs = millis(); + doneHostname = hostname; + doneTopic = topic; + } + + // Clear tracking flag when leaving docking — but preserve it + // through DOCKING -> CLEANINGSUSPENDED (mid-clean recharge) + if (wasDocking && !isDocking && !isSuspended) { + wasCleaningBeforeDock = false; + } + } + + // Update adaptive interval based on current state + bool active = isActiveState(ui); + setInterval(active ? NOTIF_INTERVAL_ACTIVE_MS : NOTIF_INTERVAL_IDLE_MS); + prevUiState = ui; + prevRobotState = rs; + } + + if (errOk) { + // New error or alert detected: was no error -> now has error, or code changed + if (err.hasError && (!prevHasError || err.errorCode != prevErrorCode)) { + bool isAlert = (err.kind == "warning"); // UI_ALERT_* (201-242) + bool allowed = isAlert ? cfg.ntfyOnAlert : cfg.ntfyOnError; + if (allowed) { + String tag = isAlert ? "information_source" : "warning"; + sendNotification(topic, tag, hostname + ": " + err.displayMessage); + } + } + prevHasError = err.hasError; + prevErrorCode = err.errorCode; + } + }); + }); +} + +bool NotificationManager::isActiveState(const String& uiState) { + return uiState.indexOf("CLEANINGRUNNING") >= 0 || uiState.indexOf("CLEANINGPAUSED") >= 0 || + uiState.indexOf("CLEANINGSUSPENDED") >= 0 || uiState.indexOf("DOCKING") >= 0; +} + +void NotificationManager::flushPendingDone() { + const LastCleanStats& stats = history.getLastCleanStats(); + bool finalized = stats.sessionId != doneTriggerSessionId; + bool timedOut = (millis() - donePendingSinceMs) >= NTFY_DONE_PENDING_TIMEOUT_MS; + if (!finalized && !timedOut) + return; + + // If stopCollection finalized after we triggered, sessionId moved and stats + // are fresh; if we timed out, fire bare without stats rather than stale. + sendDoneNotification(doneTopic, doneHostname, finalized && stats.valid); + donePending = false; +} + +void NotificationManager::sendDoneNotification(const String& topic, const String& hostname, bool withStats) { + String msg = hostname + ": Cleaning done"; + if (withStats) { + const LastCleanStats& stats = history.getLastCleanStats(); + long mins = stats.durationSec / 60; + msg += "\n" + String(mins) + "min"; + msg += " | " + String(stats.areaCoveredM2, 1) + "m2"; + msg += " | " + String(stats.distanceM, 0) + "m"; + if (stats.batteryStart >= 0 && stats.batteryEnd >= 0) { + msg += " | " + String(stats.batteryStart) + "% -> " + String(stats.batteryEnd) + "%"; + } + } + sendNotification(topic, "white_check_mark", msg); +} + +void NotificationManager::sendNotification(const String& topic, const String& tags, const String& message) { + LOG("NOTIF", "Sending: [%s] %s", tags.c_str(), message.c_str()); + + const Settings& cfg = settings.get(); + String host = cfg.ntfyServer.isEmpty() ? NTFY_DEFAULT_HOST : cfg.ntfyServer; + bool useHttps = !cfg.ntfyServer.isEmpty(); // Custom servers use HTTPS; ntfy.sh uses plain HTTP + + WiFiClientSecure secureClient; + WiFiClient plainClient; + Client *client; + + if (useHttps) { + secureClient.setInsecure(); // Skip cert validation — self-hosted server + secureClient.setTimeout(NTFY_CONNECT_TIMEOUT_MS); + if (!secureClient.connect(host.c_str(), 443)) { + LOG("NOTIF", "TLS connect to %s:443 failed", host.c_str()); + dataLogger.logNotification("notif_send_fail", message, false); + return; + } + client = &secureClient; + } else { + plainClient.setTimeout(NTFY_CONNECT_TIMEOUT_MS); + if (!plainClient.connect(host.c_str(), 80)) { + LOG("NOTIF", "Connect to %s:80 failed", host.c_str()); + dataLogger.logNotification("notif_send_fail", message, false); + return; + } + client = &plainClient; + } + + // Build HTTP POST request + client->print("POST /" + topic + " HTTP/1.1\r\n"); + client->print("Host: " + host + "\r\n"); + client->print("Content-Type: text/plain\r\n"); + client->print("Tags: " + tags + "\r\n"); + client->print("Content-Length: " + String(message.length()) + "\r\n"); + if (!cfg.ntfyToken.isEmpty()) { + client->print("Authorization: Bearer " + cfg.ntfyToken + "\r\n"); + } + client->print("Connection: close\r\n"); + client->print("\r\n"); + client->print(message); + + // Read just the HTTP status line (e.g. "HTTP/1.1 200 OK\r\n") + bool ok = false; + String statusLine = client->readStringUntil('\n'); + if (statusLine.length() > 0) { + int spaceIdx = statusLine.indexOf(' '); + if (spaceIdx > 0) { + int code = statusLine.substring(spaceIdx + 1, spaceIdx + 4).toInt(); + ok = (code >= 200 && code < 300); + LOG("NOTIF", "Response: %d %s", code, ok ? "OK" : "FAIL"); + } + } + + client->stop(); + + dataLogger.logNotification("notif_sent", message, ok); +} + +void NotificationManager::sendTestNotification(const String& topic) { + const String& hostname = settings.get().hostname; + sendNotification(topic, "bell", hostname + ": Test notification"); +} diff --git a/firmware/src/notification_manager.h b/firmware/src/notification_manager.h index 1420874..4f2493b 100644 --- a/firmware/src/notification_manager.h +++ b/firmware/src/notification_manager.h @@ -1,59 +1,58 @@ -#ifndef NOTIFICATION_MANAGER_H -#define NOTIFICATION_MANAGER_H - -#include -#include "config.h" -#include "loop_task.h" - -class NeatoSerial; -class SettingsManager; -class DataLogger; -class CleaningHistory; - -class NotificationManager : public LoopTask { -public: - NotificationManager(NeatoSerial& neato, SettingsManager& settings, DataLogger& logger, - CleaningHistory& history); - - void begin(); - - // Send a test notification to the given topic (called from web server) - void sendTestNotification(const String& topic); - -private: - void tick() override; // Called by LoopTask; skipped while fetchPending - - NeatoSerial& neato; - SettingsManager& settings; - DataLogger& dataLogger; - CleaningHistory& history; - - // Previous state for transition detection - String prevUiState; - String prevRobotState; - bool prevHasError = false; - int prevErrorCode = 200; // UI_ALERT_INVALID = no error - - // Track whether the robot was cleaning before entering docking state - bool wasCleaningBeforeDock = false; - - // Pending state fetch tracking - bool fetchPending = false; - - // Deferred "cleaning done" notification — captured at the cleaning->idle - // transition and held until CleaningHistory::stopCollection finalizes the - // session stats (sessionId increments) or a wall-clock timeout elapses. - bool donePending = false; - uint32_t doneTriggerSessionId = 0; // sessionId observed at trigger time - unsigned long donePendingSinceMs = 0; - String doneHostname; // captured hostname at trigger time - String doneTopic; // captured topic at trigger time - - void checkTransitions(); - void flushPendingDone(); - void sendDoneNotification(const String& topic, const String& hostname, bool withStats); - void sendNotification(const String& topic, const String& tags, const String& message); - static bool isActiveState(const String& uiState); -}; - -#endif // NOTIFICATION_MANAGER_H +#ifndef NOTIFICATION_MANAGER_H +#define NOTIFICATION_MANAGER_H + +#include +#include "config.h" +#include "loop_task.h" + +class NeatoSerial; +class SettingsManager; +class DataLogger; +class CleaningHistory; + +class NotificationManager : public LoopTask { +public: + NotificationManager(NeatoSerial& neato, SettingsManager& settings, DataLogger& logger, CleaningHistory& history); + + void begin(); + + // Send a test notification to the given topic (called from web server) + void sendTestNotification(const String& topic); + +private: + void tick() override; // Called by LoopTask; skipped while fetchPending + + NeatoSerial& neato; + SettingsManager& settings; + DataLogger& dataLogger; + CleaningHistory& history; + + // Previous state for transition detection + String prevUiState; + String prevRobotState; + bool prevHasError = false; + int prevErrorCode = 200; // UI_ALERT_INVALID = no error + + // Track whether the robot was cleaning before entering docking state + bool wasCleaningBeforeDock = false; + + // Pending state fetch tracking + bool fetchPending = false; + + // Deferred "cleaning done" notification — captured at the cleaning->idle + // transition and held until CleaningHistory::stopCollection finalizes the + // session stats (sessionId increments) or a wall-clock timeout elapses. + bool donePending = false; + uint32_t doneTriggerSessionId = 0; // sessionId observed at trigger time + unsigned long donePendingSinceMs = 0; + String doneHostname; // captured hostname at trigger time + String doneTopic; // captured topic at trigger time + + void checkTransitions(); + void flushPendingDone(); + void sendDoneNotification(const String& topic, const String& hostname, bool withStats); + void sendNotification(const String& topic, const String& tags, const String& message); + static bool isActiveState(const String& uiState); +}; + +#endif // NOTIFICATION_MANAGER_H diff --git a/firmware/src/settings_manager.cpp b/firmware/src/settings_manager.cpp index ff61303..affcca6 100644 --- a/firmware/src/settings_manager.cpp +++ b/firmware/src/settings_manager.cpp @@ -63,6 +63,7 @@ void SettingsManager::load() { current.brushRpm = prefs.getInt(NVS_KEY_MC_BRUSH_RPM, MANUAL_BRUSH_RPM); current.vacuumSpeed = prefs.getInt(NVS_KEY_MC_VACUUM_PCT, MANUAL_VACUUM_SPEED_PCT); current.sideBrushPower = prefs.getInt(NVS_KEY_MC_SBRUSH_MW, MANUAL_SIDE_BRUSH_POWER_MW); + current.apFallbackOnDisconnect = prefs.getBool(NVS_KEY_AP_FALLBACK, true); current.syslogEnabled = prefs.getBool(NVS_KEY_SYSLOG_ENABLED, false); current.syslogIp = prefs.getString(NVS_KEY_SYSLOG_IP, ""); current.ntfyTopic = prefs.getString(NVS_KEY_NTFY_TOPIC, ""); @@ -96,6 +97,7 @@ void SettingsManager::save() { prefs.putInt(NVS_KEY_MC_BRUSH_RPM, current.brushRpm); prefs.putInt(NVS_KEY_MC_VACUUM_PCT, current.vacuumSpeed); prefs.putInt(NVS_KEY_MC_SBRUSH_MW, current.sideBrushPower); + prefs.putBool(NVS_KEY_AP_FALLBACK, current.apFallbackOnDisconnect); prefs.putBool(NVS_KEY_SYSLOG_ENABLED, current.syslogEnabled); prefs.putString(NVS_KEY_SYSLOG_IP, current.syslogIp); prefs.putString(NVS_KEY_NTFY_TOPIC, current.ntfyTopic); @@ -256,6 +258,14 @@ ApplyResult SettingsManager::apply(const String& json) { LOG("SETTINGS", "Side brush power -> %d mW", current.sideBrushPower); } + if (incoming.apFallbackOnDisconnect != current.apFallbackOnDisconnect) { + current.apFallbackOnDisconnect = incoming.apFallbackOnDisconnect; + changed = true; + if (apFallbackChangeCb) + apFallbackChangeCb(current.apFallbackOnDisconnect); + LOG("SETTINGS", "AP fallback -> %s", current.apFallbackOnDisconnect ? "on" : "off"); + } + if (incoming.syslogEnabled != current.syslogEnabled) { current.syslogEnabled = incoming.syslogEnabled; changed = true; @@ -364,6 +374,7 @@ std::vector Settings::toFields() const { {"brushRpm", String(brushRpm), FIELD_INT}, {"vacuumSpeed", String(vacuumSpeed), FIELD_INT}, {"sideBrushPower", String(sideBrushPower), FIELD_INT}, + {"apFallbackOnDisconnect", apFallbackOnDisconnect ? "true" : "false", FIELD_BOOL}, {"syslogEnabled", syslogEnabled ? "true" : "false", FIELD_BOOL}, {"syslogIp", syslogIp, FIELD_STRING}, {"ntfyTopic", ntfyTopic, FIELD_STRING}, @@ -440,6 +451,10 @@ bool Settings::fromFields(const std::vector& fields) { sideBrushPower = f->value.toInt(); applied = true; } + if ((f = findField(fields, "apFallbackOnDisconnect")) && f->type == FIELD_BOOL) { + apFallbackOnDisconnect = (f->value == "true"); + applied = true; + } if ((f = findField(fields, "syslogEnabled")) && f->type == FIELD_BOOL) { syslogEnabled = (f->value == "true"); applied = true; diff --git a/firmware/src/settings_manager.h b/firmware/src/settings_manager.h index 6f9dec5..6f4d100 100644 --- a/firmware/src/settings_manager.h +++ b/firmware/src/settings_manager.h @@ -35,6 +35,11 @@ struct Settings : public JsonSerializable { int vacuumSpeed = MANUAL_VACUUM_SPEED_PCT; // Vacuum speed % (40-100) int sideBrushPower = MANUAL_SIDE_BRUSH_POWER_MW; // Side brush power in mW (500-1500) + // Fallback Access Point , when STA connection is lost, expose an AP for + // browser-based reconfiguration. Always on when no STA credentials are + // saved; controlled by this flag once credentials exist. + bool apFallbackOnDisconnect = true; + // Remote syslog (UDP) — when enabled, logs go to network instead of flash bool syslogEnabled = false; String syslogIp; // IPv4 address of syslog receiver @@ -84,12 +89,18 @@ class SettingsManager { using RebootCallback = std::function; void onRebootRequired(RebootCallback cb) { rebootCb = cb; } + // Callback fired when AP fallback policy changes (so WiFiManager can + // re-evaluate whether the AP should be active right now). + using ApFallbackChangeCallback = std::function; + void onApFallbackChange(ApFallbackChangeCallback cb) { apFallbackChangeCb = cb; } + private: Preferences& prefs; Settings current; TzChangeCallback tzChangeCb; TxPowerChangeCallback txPowerChangeCb; RebootCallback rebootCb; + ApFallbackChangeCallback apFallbackChangeCb; unsigned long logLevelEnabledAt = 0; // millis() when log level was changed from off (0 = off/never) void load(); diff --git a/firmware/src/system_manager.cpp b/firmware/src/system_manager.cpp index ff6db94..def26f6 100644 --- a/firmware/src/system_manager.cpp +++ b/firmware/src/system_manager.cpp @@ -1,193 +1,193 @@ -#include "system_manager.h" -#include -#include -#include -#include - -SystemManager::SystemManager(Preferences& prefs) : LoopTask(5000), prefs(prefs) { - TaskRegistry::add(this); -} - -// -- Lifecycle --------------------------------------------------------------- - -void SystemManager::begin() { - // NTP is configured by SettingsManager via applyTimezone() after settings are loaded - LOG("SYS", "System manager initialized (NTP pending timezone from settings)"); -} - -// -- Task Watchdog Timer ----------------------------------------------------- - -void SystemManager::initTaskWdt() { - // Initialize TWDT with configured timeout. If loop() stops calling - // feedTaskWdt(), the hardware watchdog resets the ESP32. -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) - // ESP-IDF 5.3+ uses config struct API - const esp_task_wdt_config_t wdtCfg = { - .timeout_ms = TASK_WDT_TIMEOUT_S * 1000, - .idle_core_mask = 0, - .trigger_panic = true, - }; - esp_task_wdt_init(&wdtCfg); -#else - esp_task_wdt_init(TASK_WDT_TIMEOUT_S, true); // true = panic (reset) on timeout -#endif - esp_task_wdt_add(nullptr); // nullptr = subscribe current task (loopTask) - LOG("SYS", "Task watchdog initialized (%ds timeout)", TASK_WDT_TIMEOUT_S); -} - -void SystemManager::feedTaskWdt() { - esp_task_wdt_reset(); -} - -void SystemManager::tick() { - // Detect NTP sync transition - if (!ntpSynced) { - time_t t = time(nullptr); - if (t > 1700000000) { - ntpSynced = true; - LOG("SYS", "NTP synced: %ld", static_cast(t)); - if (ntpSyncCallback) { - ntpSyncCallback(); - } - } - } - - // Heap watchdog — restart if free heap stays critically low for too long. - // This catches scenarios where the web server exhausts sockets/memory and - // becomes unresponsive (e.g. after a UART desync cascade). A brief dip is - // tolerated; only sustained low heap triggers the restart. - size_t freeHeap = ESP.getFreeHeap(); - if (freeHeap < HEAP_WATCHDOG_THRESHOLD) { - if (heapLowSince == 0) { - heapLowSince = millis(); - LOG("SYS", "Heap watchdog: low heap detected (%u bytes)", freeHeap); - } else if (millis() - heapLowSince >= HEAP_WATCHDOG_DURATION_MS) { - LOG("SYS", "Heap watchdog: heap critically low for %lums (%u bytes), restarting", HEAP_WATCHDOG_DURATION_MS, - freeHeap); - ESP.restart(); - } - } else { - // Heap recovered — reset the timer - if (heapLowSince > 0) { - LOG("SYS", "Heap watchdog: heap recovered (%u bytes)", freeHeap); - heapLowSince = 0; - } - } -} - -// -- Time -------------------------------------------------------------------- - -time_t SystemManager::now() const { - // Prefer NTP - time_t t = time(nullptr); - if (t > 1700000000) - return t; - - // Fall back to external clock - if (fallbackSet && fallbackEpoch > 0) { - return fallbackEpoch + (millis() - fallbackMillis) / 1000; - } - - // Last resort: uptime as pseudo-timestamp - return static_cast(millis() / 1000); -} - -void SystemManager::setFallbackClock(time_t epoch) { - fallbackEpoch = epoch; - fallbackMillis = millis(); - fallbackSet = true; - LOG("SYS", "Fallback clock set: epoch %ld", static_cast(epoch)); -} - -// -- Timezone ---------------------------------------------------------------- - -void SystemManager::applyTimezone(const String& tz) { - configTzTime(tz.c_str(), NTP_SERVER_1, NTP_SERVER_2); - LOG("SYS", "NTP timezone applied: %s", tz.c_str()); -} - -// -- Deferred reboot --------------------------------------------------------- - -void SystemManager::restart() { - LOG("SYS", "Restart scheduled"); - pendingRebootAt = millis(); -} - -void SystemManager::factoryReset() { - LOG("SYS", "Factory reset scheduled"); - pendingFactoryReset = true; - pendingRebootAt = millis(); -} - -void SystemManager::formatFs() { - LOG("SYS", "Filesystem format scheduled"); - pendingFormatFs = true; - pendingRebootAt = millis(); -} - -void SystemManager::checkPendingReboot() { - if (pendingRebootAt == 0 || millis() - pendingRebootAt < 500) - return; - - if (pendingFactoryReset) { - LOG("SYS", "Factory reset: clearing NVS, WiFi credentials, and filesystem..."); - prefs.clear(); - WiFi.disconnect(true, true); - SPIFFS.format(); - delay(500); - } else if (pendingFormatFs) { - LOG("SYS", "Formatting filesystem..."); - SPIFFS.format(); - delay(500); - } - LOG("SYS", "Rebooting..."); - ESP.restart(); -} - -// -- System health ----------------------------------------------------------- - -std::vector SystemHealth::toFields() const { - return { - {"heap", String(heap), FIELD_INT}, - {"heapTotal", String(heapTotal), FIELD_INT}, - {"uptime", String(uptime), FIELD_INT}, - {"rssi", String(rssi), FIELD_INT}, - {"fsUsed", String(fsUsed), FIELD_INT}, - {"fsTotal", String(fsTotal), FIELD_INT}, - {"ntpSynced", ntpSynced ? "true" : "false", FIELD_BOOL}, - {"time", String(static_cast(time)), FIELD_INT}, - {"timeSource", timeSource, FIELD_STRING}, - {"tz", tz, FIELD_STRING}, - {"localTime", localTime, FIELD_STRING}, - {"isDst", isDst ? "true" : "false", FIELD_BOOL}, - }; -} - -SystemHealth SystemManager::getSystemHealth(const String& tz) const { - SystemHealth h; - h.heap = ESP.getFreeHeap(); - h.heapTotal = ESP.getHeapSize(); - h.uptime = millis(); - h.rssi = WiFi.RSSI(); - h.fsUsed = SPIFFS.usedBytes(); - h.fsTotal = SPIFFS.totalBytes(); - h.ntpSynced = ntpSynced; - h.time = now(); - h.timeSource = ntpSynced ? "ntp" : (fallbackSet ? "fallback" : "millis"); - h.tz = tz; - - // Compute DST-aware local time string via localtime_r (same conversion the - // scheduler uses). The POSIX TZ string applied via configTzTime() handles - // DST transitions, so this is always correct for the configured timezone. - if (h.time > 1700000000) { - struct tm tm; - localtime_r(&h.time, &tm); - static const char *DAYS[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; - char buf[20]; - snprintf(buf, sizeof(buf), "%s %02d:%02d:%02d", DAYS[tm.tm_wday], tm.tm_hour, tm.tm_min, tm.tm_sec); - h.localTime = buf; - h.isDst = tm.tm_isdst > 0; - } - - return h; -} +#include "system_manager.h" +#include +#include +#include +#include + +SystemManager::SystemManager(Preferences& prefs) : LoopTask(5000), prefs(prefs) { + TaskRegistry::add(this); +} + +// -- Lifecycle --------------------------------------------------------------- + +void SystemManager::begin() { + // NTP is configured by SettingsManager via applyTimezone() after settings are loaded + LOG("SYS", "System manager initialized (NTP pending timezone from settings)"); +} + +// -- Task Watchdog Timer ----------------------------------------------------- + +void SystemManager::initTaskWdt() { + // Initialize TWDT with configured timeout. If loop() stops calling + // feedTaskWdt(), the hardware watchdog resets the ESP32. +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) + // ESP-IDF 5.3+ uses config struct API + const esp_task_wdt_config_t wdtCfg = { + .timeout_ms = TASK_WDT_TIMEOUT_S * 1000, + .idle_core_mask = 0, + .trigger_panic = true, + }; + esp_task_wdt_init(&wdtCfg); +#else + esp_task_wdt_init(TASK_WDT_TIMEOUT_S, true); // true = panic (reset) on timeout +#endif + esp_task_wdt_add(nullptr); // nullptr = subscribe current task (loopTask) + LOG("SYS", "Task watchdog initialized (%ds timeout)", TASK_WDT_TIMEOUT_S); +} + +void SystemManager::feedTaskWdt() { + esp_task_wdt_reset(); +} + +void SystemManager::tick() { + // Detect NTP sync transition + if (!ntpSynced) { + time_t t = time(nullptr); + if (t > 1700000000) { + ntpSynced = true; + LOG("SYS", "NTP synced: %ld", static_cast(t)); + if (ntpSyncCallback) { + ntpSyncCallback(); + } + } + } + + // Heap watchdog — restart if free heap stays critically low for too long. + // This catches scenarios where the web server exhausts sockets/memory and + // becomes unresponsive (e.g. after a UART desync cascade). A brief dip is + // tolerated; only sustained low heap triggers the restart. + size_t freeHeap = ESP.getFreeHeap(); + if (freeHeap < HEAP_WATCHDOG_THRESHOLD) { + if (heapLowSince == 0) { + heapLowSince = millis(); + LOG("SYS", "Heap watchdog: low heap detected (%u bytes)", freeHeap); + } else if (millis() - heapLowSince >= HEAP_WATCHDOG_DURATION_MS) { + LOG("SYS", "Heap watchdog: heap critically low for %lums (%u bytes), restarting", HEAP_WATCHDOG_DURATION_MS, + freeHeap); + ESP.restart(); + } + } else { + // Heap recovered — reset the timer + if (heapLowSince > 0) { + LOG("SYS", "Heap watchdog: heap recovered (%u bytes)", freeHeap); + heapLowSince = 0; + } + } +} + +// -- Time -------------------------------------------------------------------- + +time_t SystemManager::now() const { + // Prefer NTP + time_t t = time(nullptr); + if (t > 1700000000) + return t; + + // Fall back to external clock + if (fallbackSet && fallbackEpoch > 0) { + return fallbackEpoch + (millis() - fallbackMillis) / 1000; + } + + // Last resort: uptime as pseudo-timestamp + return static_cast(millis() / 1000); +} + +void SystemManager::setFallbackClock(time_t epoch) { + fallbackEpoch = epoch; + fallbackMillis = millis(); + fallbackSet = true; + LOG("SYS", "Fallback clock set: epoch %ld", static_cast(epoch)); +} + +// -- Timezone ---------------------------------------------------------------- + +void SystemManager::applyTimezone(const String& tz) { + configTzTime(tz.c_str(), NTP_SERVER_1, NTP_SERVER_2); + LOG("SYS", "NTP timezone applied: %s", tz.c_str()); +} + +// -- Deferred reboot --------------------------------------------------------- + +void SystemManager::restart() { + LOG("SYS", "Restart scheduled"); + pendingRebootAt = millis(); +} + +void SystemManager::factoryReset() { + LOG("SYS", "Factory reset scheduled"); + pendingFactoryReset = true; + pendingRebootAt = millis(); +} + +void SystemManager::formatFs() { + LOG("SYS", "Filesystem format scheduled"); + pendingFormatFs = true; + pendingRebootAt = millis(); +} + +void SystemManager::checkPendingReboot() { + if (pendingRebootAt == 0 || millis() - pendingRebootAt < 500) + return; + + if (pendingFactoryReset) { + LOG("SYS", "Factory reset: clearing NVS, WiFi credentials, and filesystem..."); + prefs.clear(); + WiFi.disconnect(true, true); + SPIFFS.format(); + delay(500); + } else if (pendingFormatFs) { + LOG("SYS", "Formatting filesystem..."); + SPIFFS.format(); + delay(500); + } + LOG("SYS", "Rebooting..."); + ESP.restart(); +} + +// -- System health ----------------------------------------------------------- + +std::vector SystemHealth::toFields() const { + return { + {"heap", String(heap), FIELD_INT}, + {"heapTotal", String(heapTotal), FIELD_INT}, + {"uptime", String(uptime), FIELD_INT}, + {"rssi", String(rssi), FIELD_INT}, + {"fsUsed", String(fsUsed), FIELD_INT}, + {"fsTotal", String(fsTotal), FIELD_INT}, + {"ntpSynced", ntpSynced ? "true" : "false", FIELD_BOOL}, + {"time", String(static_cast(time)), FIELD_INT}, + {"timeSource", timeSource, FIELD_STRING}, + {"tz", tz, FIELD_STRING}, + {"localTime", localTime, FIELD_STRING}, + {"isDst", isDst ? "true" : "false", FIELD_BOOL}, + }; +} + +SystemHealth SystemManager::getSystemHealth(const String& tz) const { + SystemHealth h; + h.heap = ESP.getFreeHeap(); + h.heapTotal = ESP.getHeapSize(); + h.uptime = millis(); + h.rssi = WiFi.RSSI(); + h.fsUsed = SPIFFS.usedBytes(); + h.fsTotal = SPIFFS.totalBytes(); + h.ntpSynced = ntpSynced; + h.time = now(); + h.timeSource = ntpSynced ? "ntp" : (fallbackSet ? "fallback" : "millis"); + h.tz = tz; + + // Compute DST-aware local time string via localtime_r (same conversion the + // scheduler uses). The POSIX TZ string applied via configTzTime() handles + // DST transitions, so this is always correct for the configured timezone. + if (h.time > 1700000000) { + struct tm tm; + localtime_r(&h.time, &tm); + static const char *DAYS[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + char buf[20]; + snprintf(buf, sizeof(buf), "%s %02d:%02d:%02d", DAYS[tm.tm_wday], tm.tm_hour, tm.tm_min, tm.tm_sec); + h.localTime = buf; + h.isDst = tm.tm_isdst > 0; + } + + return h; +} diff --git a/firmware/src/web_server.cpp b/firmware/src/web_server.cpp index a29012d..168583d 100644 --- a/firmware/src/web_server.cpp +++ b/firmware/src/web_server.cpp @@ -1,505 +1,538 @@ -#include "web_server.h" -#include "web_assets.h" -#include "neato_serial.h" -#include "data_logger.h" -#include "system_manager.h" -#include "settings_manager.h" -#include "firmware_manager.h" -#include "manual_clean_manager.h" -#include "notification_manager.h" -#include "cleaning_history.h" -#include - -unsigned long WebServer::lastApiActivity = 0; - -WebServer::WebServer(AsyncWebServer& server, NeatoSerial& neato, DataLogger& logger, SystemManager& sys, - FirmwareManager& fw, SettingsManager& settings, ManualCleanManager& manual, - NotificationManager& notif, CleaningHistory& history) : - server(server), neato(neato), logger(logger), sysMgr(sys), fwMgr(fw), settingsMgr(settings), manualMgr(manual), - notifMgr(notif), historyMgr(history) {} - -void WebServer::loggedRoute(const char *path, WebRequestMethodComposite httpMethod, SyncHandler handler) { - server.on(path, httpMethod, [this, handler](AsyncWebServerRequest *request) { - lastApiActivity = millis(); - unsigned long startMs = lastApiActivity; - int status = handler(request); - logger.logRequest(request->method(), request->url().c_str(), status, millis() - startMs); - }); -} - -void WebServer::loggedBodyRoute(const char *path, WebRequestMethodComposite httpMethod, BodyHandler handler) { - server.on( - path, httpMethod, [](AsyncWebServerRequest *request) { /* handled in body callback */ }, nullptr, - [this, handler](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t, size_t) { - lastApiActivity = millis(); - unsigned long startMs = lastApiActivity; - int status = handler(request, data, len); - logger.logRequest(request->method(), request->url().c_str(), status, millis() - startMs); - }); -} - -void WebServer::sendGzipAsset(AsyncWebServerRequest *request, const uint8_t *data, size_t len, - const char *contentType) { - AsyncWebServerResponse *response = request->beginResponse(200, contentType, data, len); - response->addHeader("Content-Encoding", "gzip"); - request->send(response); -} - -void WebServer::sendError(AsyncWebServerRequest *request, int code, const String& msg) { - request->send(code, "application/json", fieldsToJson({{"error", msg, FIELD_STRING}})); -} - -void WebServer::sendOk(AsyncWebServerRequest *request) { - request->send(200, "application/json", fieldsToJson({{"ok", "true", FIELD_BOOL}})); -} - -void WebServer::begin() { - // Register all embedded frontend assets from the auto-generated registry - for (size_t i = 0; i < WEB_ASSETS_COUNT; i++) { - const WebAsset& asset = WEB_ASSETS[i]; - server.on(asset.path, HTTP_GET, [&asset](AsyncWebServerRequest *request) { - sendGzipAsset(request, asset.data, asset.length, asset.contentType); - }); - } - - LOG("WEB", "Registered %u embedded assets", WEB_ASSETS_COUNT); - - registerApiRoutes(); - registerManualRoutes(); - registerLogRoutes(); - registerSystemRoutes(); - registerSettingsRoutes(); - registerFirmwareRoutes(); - registerMapRoutes(); - - LOG("WEB", "Frontend and API routes registered"); -} - -void WebServer::registerApiRoutes() { - // -- Sensor query endpoints ---------------------------------------------- - - registerGetRoute("/api/version", neato, &NeatoSerial::getVersion, {}); - registerGetRoute("/api/charger", neato, &NeatoSerial::getCharger, {}); - registerGetRoute( - "/api/motors", neato, - static_cast)>(&NeatoSerial::getMotors), - {}); - registerGetRoute("/api/state", neato, &NeatoSerial::getState, {}); - registerGetRoute("/api/error", neato, &NeatoSerial::getErr, {}); - registerGetRoute("/api/lidar", neato, &NeatoSerial::getLdsScan, {}); - registerGetRoute("/api/user-settings", neato, &NeatoSerial::getUserSettings, {}); - registerGetRoute( - "/api/sensors", neato, - static_cast)>( - &NeatoSerial::getDigitalSensors), - {}); - - // -- Action endpoints ---------------------------------------------------- - // All parameterized actions use query strings: resource URL identifies the - // command, query params carry arguments (mirrors Neato serial protocol). - - registerPostRoute("/api/clean", neato, &NeatoSerial::clean, {"action"}); - registerPostRoute("/api/sound", neato, &NeatoSerial::playSound, {"id"}); - registerPostRoute("/api/testmode", neato, &NeatoSerial::testMode, {"enable"}); - registerPostRoute("/api/power", neato, &NeatoSerial::powerControl, {"action"}); - registerPostRoute("/api/lidar/rotate", neato, &NeatoSerial::setLdsRotation, {"enable"}); - registerPostRoute("/api/user-settings", neato, &NeatoSerial::setUserSetting, {"key", "value"}); - registerPostRoute("/api/clear-errors", neato, &NeatoSerial::clearErrors, {}); - - // Serial endpoint — send arbitrary serial command, returns raw response. - // Always available (no debug gate — useful for diagnostics without enabling verbose logging). - server.on("/api/serial", HTTP_POST, [this](AsyncWebServerRequest *request) { - lastApiActivity = millis(); - unsigned long startMs = lastApiActivity; - - if (!request->hasParam("cmd")) { - logger.logRequest(HTTP_POST, "/api/serial", 400, millis() - startMs); - sendError(request, 400, "missing cmd"); - return; - } - String cmd = request->getParam("cmd")->value(); - if (cmd.isEmpty()) { - logger.logRequest(HTTP_POST, "/api/serial", 400, millis() - startMs); - sendError(request, 400, "empty cmd"); - return; - } - - auto weak = request->pause(); - bool ok = neato.sendRaw(cmd, [this, weak, startMs](bool /*success*/, const String& response) { - if (auto req = weak.lock()) { - unsigned long elapsed = millis() - startMs; - logger.logRequest(HTTP_POST, "/api/serial", 200, elapsed); - req->send(200, "text/plain", response); - } - }); - if (!ok) { - logger.logRequest(HTTP_POST, "/api/serial", 503, millis() - startMs); - sendError(request, 503, "unavailable"); - } - }); - - LOG("WEB", "API routes registered"); -} - -// -- Manual clean endpoints --------------------------------------------------- - -void WebServer::registerManualRoutes() { - // Register longer paths first — ESPAsyncWebServer matches routes by prefix, - // so /api/manual would swallow /api/manual/move and /api/manual/motors. - loggedRoute("/api/manual/status", HTTP_GET, [this](AsyncWebServerRequest *request) { - request->send(200, "application/json", manualMgr.getStatusJson()); - return 200; - }); - registerPostRoute("/api/manual/move", manualMgr, &ManualCleanManager::move, {"left", "right", "speed"}); - registerPostRoute("/api/manual/motors", manualMgr, &ManualCleanManager::setMotors, - {"brush", "vacuum", "sideBrush"}); - registerPostRoute("/api/manual", manualMgr, &ManualCleanManager::enable, {"enable"}); - - LOG("WEB", "Manual clean routes registered"); -} - -// -- Log file endpoints ------------------------------------------------------ - -// Strip .hs extension so browser saves a plain .jsonl file -static String downloadName(const String& filename) { - if (filename.endsWith(".hs")) - return filename.substring(0, filename.length() - 3); - return filename; -} - -static String logListJson(const std::vector& files) { - String json = "["; - for (size_t i = 0; i < files.size(); i++) { - if (i > 0) - json += ","; - json += files[i].toJson(); - } - json += "]"; - return json; -} - -void WebServer::registerLogRoutes() { - // GET /api/logs[/filename] — list logs or download a specific file - // A single BackwardCompatible handler matches both "/api/logs" and "/api/logs/..." - // This route uses server.on() directly instead of loggedRoute() because - // compressed log downloads use chunked streaming (beginChunkedResponse) which - // must not block — loggedRoute's sync wrapper would block until completion. - server.on("/api/logs", HTTP_GET, [this](AsyncWebServerRequest *request) { - unsigned long startMs = millis(); - String filename = request->url().substring(String("/api/logs/").length()); - - if (filename.isEmpty()) { - String json = logListJson(logger.listLogs()); - logger.logRequest(HTTP_GET, "/api/logs", 200, millis() - startMs); - request->send(200, "application/json", json); - return; - } - - // Open log via DataLogger — handles path resolution and transparent decompression - auto reader = logger.readLog(filename); - if (!reader) { - logger.logRequest(HTTP_GET, request->url().c_str(), 404, millis() - startMs); - sendError(request, 404, "log not found"); - return; - } - - logger.logRequest(HTTP_GET, request->url().c_str(), 200, millis() - startMs); - - // Stream log content via chunked response — reader handles decompression - AsyncWebServerResponse *response = request->beginChunkedResponse( - "application/x-ndjson", - [reader](uint8_t *buffer, size_t maxLen, size_t) -> size_t { return reader->read(buffer, maxLen); }); - - response->addHeader("Content-Disposition", "attachment; filename=\"" + downloadName(filename) + "\""); - - request->send(response); - }); - - // DELETE /api/logs[/filename] — delete all logs or a specific file - loggedRoute("/api/logs", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { - String filename = request->url().substring(String("/api/logs/").length()); - - if (filename.isEmpty()) { - logger.deleteAllLogs(); - sendOk(request); - return 200; - } - - if (logger.deleteLog(filename)) { - sendOk(request); - return 200; - } - - sendError(request, 404, "log not found"); - return 404; - }); - - LOG("WEB", "Log routes registered"); -} - -// -- System health endpoint --------------------------------------------------- - -void WebServer::registerSystemRoutes() { - // GET /api/system — live system health (heap, uptime, RSSI, storage, NTP) - loggedRoute("/api/system", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { - request->send(200, "application/json", sysMgr.getSystemHealth(settingsMgr.get().tz).toJson()); - return 200; - }); - - // POST /api/system/restart — deferred restart - loggedRoute("/api/system/restart", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { - sendOk(request); - sysMgr.restart(); - return 200; - }); - - // POST /api/system/reset — factory reset (clears NVS + WiFi, then restarts) - loggedRoute("/api/system/reset", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { - sendOk(request); - sysMgr.factoryReset(); - return 200; - }); - - // POST /api/system/format-fs — format filesystem (erases logs + map data, then restarts) - loggedRoute("/api/system/format-fs", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { - sendOk(request); - sysMgr.formatFs(); - return 200; - }); - - LOG("WEB", "System routes registered"); -} - -// -- Settings endpoint ------------------------------------------------------- - -void WebServer::registerSettingsRoutes() { - // GET /api/settings — all user-configurable settings - loggedRoute("/api/settings", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { - request->send(200, "application/json", settingsMgr.get().toJson()); - return 200; - }); - - // PUT /api/settings — partial update (only fields present are written) - loggedBodyRoute("/api/settings", HTTP_PUT, - [this](AsyncWebServerRequest *request, uint8_t *data, size_t len) -> int { - String body = String(reinterpret_cast(data), len); - ApplyResult result = settingsMgr.apply(body); - if (result == APPLY_INVALID) { - sendError(request, 400, "Invalid settings"); - return 400; - } - if (result == APPLY_CHANGED) { - // Push manual clean settings to manager (no reboot needed) - const auto& s = settingsMgr.get(); - manualMgr.setStallThreshold(s.stallThreshold); - manualMgr.setBrushRpm(s.brushRpm); - manualMgr.setVacuumSpeed(s.vacuumSpeed); - manualMgr.setSideBrushPower(s.sideBrushPower); - } - request->send(200, "application/json", settingsMgr.get().toJson()); - return 200; - }); - - // POST /api/notifications/test?topic= — send a test notification - loggedRoute("/api/notifications/test", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { - if (!request->hasParam("topic")) { - sendError(request, 400, "missing topic"); - return 400; - } - String topic = request->getParam("topic")->value(); - if (topic.isEmpty()) { - sendError(request, 400, "topic cannot be empty"); - return 400; - } - notifMgr.sendTestNotification(topic); - sendOk(request); - return 200; - }); - - LOG("WEB", "Settings routes registered"); -} - -// -- Firmware endpoints ------------------------------------------------------- - -void WebServer::registerFirmwareRoutes() { - // GET /api/firmware/version — current ESP32 firmware version + chip model + robot support status - loggedRoute("/api/firmware/version", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { - std::vector fields = { - {"version", fwMgr.getFirmwareVersion(), FIELD_STRING}, - {"chip", fwMgr.getChipModel(), FIELD_STRING}, - {"model", neato.getModelName(), FIELD_STRING}, - {"hostname", settingsMgr.get().hostname, FIELD_STRING}, - {"supported", isSupportedModel(neato.getModelName()) ? "true" : "false", FIELD_BOOL}, - {"identifying", neato.isIdentifying() ? "true" : "false", FIELD_BOOL}, - }; - request->send(200, "application/json", fieldsToJson(fields)); - return 200; - }); - - // POST /api/firmware/update?hash= — single-request firmware upload - server.on( - "/api/firmware/update", HTTP_POST, - // Response handler (called after upload completes) - [this](AsyncWebServerRequest *request) { - unsigned long startMs = millis(); - bool ok = fwMgr.getError().isEmpty(); - - if (ok) { - ok = fwMgr.endUpdate(); - } - - if (!ok) { - logger.logRequest(HTTP_POST, "/api/firmware/update", 400, millis() - startMs); - sendError(request, 400, fwMgr.getError()); - } else { - logger.logRequest(HTTP_POST, "/api/firmware/update", 200, millis() - startMs); - AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "OK"); - response->addHeader("Connection", "close"); - request->send(response); - } - }, - // Upload handler (called per chunk) - [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, - bool final) { - // First chunk: initialize update session - if (!index) { - String md5 = request->hasParam("hash") ? request->getParam("hash")->value() : ""; - if (!fwMgr.beginUpdate(md5)) { - return; - } - } - - if (len) { - fwMgr.writeChunk(data, len); - - // Report progress at most once per second - static unsigned long lastProgressMs = 0; - unsigned long now = millis(); - if (now - lastProgressMs >= 1000) { - auto percent = static_cast( - request->contentLength() > 0 ? static_cast(fwMgr.getProgress()) * 100.0f / - static_cast(request->contentLength()) - : 0); - LOG("FW", "Progress: %u%% (%zu/%zu bytes)", percent, fwMgr.getProgress(), - request->contentLength()); - lastProgressMs = now; - } - } - }); - - LOG("WEB", "Firmware routes registered"); -} - -// -- Map data endpoints ------------------------------------------------------- - -void WebServer::registerMapRoutes() { - // GET /api/history[/filename] — list sessions, collection status, or download a specific file - server.on("/api/history", HTTP_GET, [this](AsyncWebServerRequest *request) { - lastApiActivity = millis(); - unsigned long startMs = lastApiActivity; - String suffix = request->url().substring(String("/api/history/").length()); - - if (suffix.isEmpty()) { - // List all session files with embedded session/summary metadata - auto sessions = historyMgr.listSessions(); - String json = "["; - for (size_t i = 0; i < sessions.size(); i++) { - if (i > 0) - json += ","; - const auto& s = sessions[i]; - json += R"({"name":")" + s.name + R"(","size":)" + String(static_cast(s.size)) + - R"(,"compressed":)" + String(s.compressed ? "true" : "false") + R"(,"recording":)" + - String(s.recording ? "true" : "false"); - if (s.session.length() > 0) { - json += ",\"session\":" + s.session; - } else { - json += ",\"session\":null"; - } - if (s.summary.length() > 0) { - json += ",\"summary\":" + s.summary; - } else { - json += ",\"summary\":null"; - } - json += "}"; - } - json += "]"; - logger.logRequest(HTTP_GET, "/api/history", 200, millis() - startMs); - request->send(200, "application/json", json); - return; - } - - // Download specific session - auto reader = historyMgr.readSession(suffix); - if (!reader) { - logger.logRequest(HTTP_GET, request->url().c_str(), 404, millis() - startMs); - sendError(request, 404, "session not found"); - return; - } - - logger.logRequest(HTTP_GET, request->url().c_str(), 200, millis() - startMs); - - AsyncWebServerResponse *response = request->beginChunkedResponse( - "application/x-ndjson", - [reader](uint8_t *buffer, size_t maxLen, size_t) -> size_t { return reader->read(buffer, maxLen); }); - - response->addHeader("Content-Disposition", "attachment; filename=\"" + downloadName(suffix) + "\""); - - request->send(response); - }); - - // DELETE /api/history[/filename] — delete one or all sessions - loggedRoute("/api/history", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { - String filename = request->url().substring(String("/api/history/").length()); - - if (filename.isEmpty()) { - historyMgr.deleteAllSessions(); - sendOk(request); - return 200; - } - - if (historyMgr.deleteSession(filename)) { - sendOk(request); - return 200; - } - - sendError(request, 404, "session not found"); - return 404; - }); - - // POST /api/history/import — upload a .jsonl session file, compress and store - server.on( - "/api/history/import", HTTP_POST, - // Response handler (called after upload completes) - [this](AsyncWebServerRequest *request) { - unsigned long startMs = millis(); - bool ok = historyMgr.getImportError().isEmpty(); - - if (ok) { - ok = historyMgr.endImport(); - } - - if (!ok) { - logger.logRequest(HTTP_POST, "/api/history/import", 400, millis() - startMs); - sendError(request, 400, historyMgr.getImportError()); - } else { - logger.logRequest(HTTP_POST, "/api/history/import", 200, millis() - startMs); - sendOk(request); - } - }, - // Upload handler (called per chunk) - [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, - bool final) { - // First chunk: initialize import session - if (!index) { - if (!historyMgr.beginImport(filename)) { - return; - } - } - - if (len && historyMgr.isImporting()) { - historyMgr.writeImportChunk(data, len); - } - }); - - LOG("WEB", "History routes registered"); -} +#include "web_server.h" +#include "web_assets.h" +#include "neato_serial.h" +#include "data_logger.h" +#include "system_manager.h" +#include "settings_manager.h" +#include "firmware_manager.h" +#include "manual_clean_manager.h" +#include "notification_manager.h" +#include "cleaning_history.h" +#include "wifi_manager.h" +#include + +unsigned long WebServer::lastApiActivity = 0; + +WebServer::WebServer(AsyncWebServer& server, NeatoSerial& neato, DataLogger& logger, SystemManager& sys, + FirmwareManager& fw, SettingsManager& settings, ManualCleanManager& manual, + NotificationManager& notif, CleaningHistory& history, WiFiManager& wifi) : + server(server), neato(neato), logger(logger), sysMgr(sys), fwMgr(fw), settingsMgr(settings), manualMgr(manual), + notifMgr(notif), historyMgr(history), wifiMgr(wifi) {} + +void WebServer::loggedRoute(const char *path, WebRequestMethodComposite httpMethod, SyncHandler handler) { + server.on(path, httpMethod, [this, handler](AsyncWebServerRequest *request) { + lastApiActivity = millis(); + unsigned long startMs = lastApiActivity; + int status = handler(request); + logger.logRequest(request->method(), request->url().c_str(), status, millis() - startMs); + }); +} + +void WebServer::loggedBodyRoute(const char *path, WebRequestMethodComposite httpMethod, BodyHandler handler) { + server.on( + path, httpMethod, [](AsyncWebServerRequest *request) { /* handled in body callback */ }, nullptr, + [this, handler](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t, size_t) { + lastApiActivity = millis(); + unsigned long startMs = lastApiActivity; + int status = handler(request, data, len); + logger.logRequest(request->method(), request->url().c_str(), status, millis() - startMs); + }); +} + +void WebServer::sendGzipAsset(AsyncWebServerRequest *request, const uint8_t *data, size_t len, + const char *contentType) { + AsyncWebServerResponse *response = request->beginResponse(200, contentType, data, len); + response->addHeader("Content-Encoding", "gzip"); + request->send(response); +} + +void WebServer::sendError(AsyncWebServerRequest *request, int code, const String& msg) { + request->send(code, "application/json", fieldsToJson({{"error", msg, FIELD_STRING}})); +} + +void WebServer::sendOk(AsyncWebServerRequest *request) { + request->send(200, "application/json", fieldsToJson({{"ok", "true", FIELD_BOOL}})); +} + +void WebServer::begin() { + // Register all embedded frontend assets from the auto-generated registry + for (size_t i = 0; i < WEB_ASSETS_COUNT; i++) { + const WebAsset& asset = WEB_ASSETS[i]; + server.on(asset.path, HTTP_GET, [&asset](AsyncWebServerRequest *request) { + sendGzipAsset(request, asset.data, asset.length, asset.contentType); + }); + } + + LOG("WEB", "Registered %u embedded assets", WEB_ASSETS_COUNT); + + registerApiRoutes(); + registerManualRoutes(); + registerLogRoutes(); + registerSystemRoutes(); + registerSettingsRoutes(); + registerFirmwareRoutes(); + registerMapRoutes(); + registerWiFiRoutes(); + + LOG("WEB", "Frontend and API routes registered"); +} + +void WebServer::registerApiRoutes() { + // -- Sensor query endpoints ---------------------------------------------- + + registerGetRoute("/api/version", neato, &NeatoSerial::getVersion, {}); + registerGetRoute("/api/charger", neato, &NeatoSerial::getCharger, {}); + registerGetRoute("/api/analog", neato, &NeatoSerial::getBatteryAnalog, {}); + registerGetRoute("/api/warranty", neato, &NeatoSerial::getBatteryWarranty, {}); + registerGetRoute( + "/api/motors", neato, + static_cast)>(&NeatoSerial::getMotors), + {}); + registerGetRoute("/api/state", neato, &NeatoSerial::getState, {}); + registerGetRoute("/api/error", neato, &NeatoSerial::getErr, {}); + registerGetRoute("/api/lidar", neato, &NeatoSerial::getLdsScan, {}); + registerGetRoute("/api/user-settings", neato, &NeatoSerial::getUserSettings, {}); + registerGetRoute("/api/sensors", neato, + static_cast)>( + &NeatoSerial::getDigitalSensors), + {}); + + // -- Action endpoints ---------------------------------------------------- + // All parameterized actions use query strings: resource URL identifies the + // command, query params carry arguments (mirrors Neato serial protocol). + + registerPostRoute("/api/clean", neato, &NeatoSerial::clean, {"action"}); + registerPostRoute("/api/sound", neato, &NeatoSerial::playSound, {"id"}); + registerPostRoute("/api/power", neato, &NeatoSerial::powerControl, {"action"}); + registerPostRoute("/api/lidar/rotate", neato, &NeatoSerial::setLdsRotation, {"enable"}); + registerPostRoute("/api/user-settings", neato, &NeatoSerial::setUserSetting, {"key", "value"}); + registerPostRoute("/api/clear-errors", neato, &NeatoSerial::clearErrors, {}); + registerPostRoute("/api/battery/new", neato, &NeatoSerial::newBattery, {}); + + // Serial endpoint — send arbitrary serial command, returns raw response. + // Always available (no debug gate — useful for diagnostics without enabling verbose logging). + // Excluded from public API docs (diagnostics-only passthrough). + server.on("/api/serial", HTTP_POST, [this](AsyncWebServerRequest *request) { + lastApiActivity = millis(); + unsigned long startMs = lastApiActivity; + + if (!request->hasParam("cmd")) { + logger.logRequest(HTTP_POST, "/api/serial", 400, millis() - startMs); + sendError(request, 400, "missing cmd"); + return; + } + String cmd = request->getParam("cmd")->value(); + if (cmd.isEmpty()) { + logger.logRequest(HTTP_POST, "/api/serial", 400, millis() - startMs); + sendError(request, 400, "empty cmd"); + return; + } + + auto weak = request->pause(); + bool ok = neato.sendRaw(cmd, [this, weak, startMs](bool /*success*/, const String& response) { + if (auto req = weak.lock()) { + unsigned long elapsed = millis() - startMs; + logger.logRequest(HTTP_POST, "/api/serial", 200, elapsed); + req->send(200, "text/plain", response); + } + }); + if (!ok) { + logger.logRequest(HTTP_POST, "/api/serial", 503, millis() - startMs); + sendError(request, 503, "unavailable"); + } + }); + + LOG("WEB", "API routes registered"); +} + +// -- Manual clean endpoints --------------------------------------------------- + +void WebServer::registerManualRoutes() { + // Register longer paths first — ESPAsyncWebServer matches routes by prefix, + // so /api/manual would swallow /api/manual/move and /api/manual/motors. + + loggedRoute("/api/manual/status", HTTP_GET, [this](AsyncWebServerRequest *request) { + request->send(200, "application/json", manualMgr.getStatusJson()); + return 200; + }); + registerPostRoute("/api/manual/move", manualMgr, &ManualCleanManager::move, {"left", "right", "speed"}); + registerPostRoute("/api/manual/motors", manualMgr, &ManualCleanManager::setMotors, + {"brush", "vacuum", "sideBrush"}); + registerPostRoute("/api/manual", manualMgr, &ManualCleanManager::enable, {"enable"}); + + LOG("WEB", "Manual clean routes registered"); +} + +// -- Log file endpoints ------------------------------------------------------ + +// Strip .hs extension so browser saves a plain .jsonl file +static String downloadName(const String& filename) { + if (filename.endsWith(".hs")) + return filename.substring(0, filename.length() - 3); + return filename; +} + +static String logListJson(const std::vector& files) { + String json = "["; + for (size_t i = 0; i < files.size(); i++) { + if (i > 0) + json += ","; + json += files[i].toJson(); + } + json += "]"; + return json; +} + +void WebServer::registerLogRoutes() { + + // GET /api/logs[/filename] — list logs or download a specific file + // A single BackwardCompatible handler matches both "/api/logs" and "/api/logs/..." + // This route uses server.on() directly instead of loggedRoute() because + // compressed log downloads use chunked streaming (beginChunkedResponse) which + // must not block — loggedRoute's sync wrapper would block until completion. + server.on("/api/logs", HTTP_GET, [this](AsyncWebServerRequest *request) { + unsigned long startMs = millis(); + String filename = request->url().substring(String("/api/logs/").length()); + + if (filename.isEmpty()) { + String json = logListJson(logger.listLogs()); + logger.logRequest(HTTP_GET, "/api/logs", 200, millis() - startMs); + request->send(200, "application/json", json); + return; + } + + // Open log via DataLogger — handles path resolution and transparent decompression + auto reader = logger.readLog(filename); + if (!reader) { + logger.logRequest(HTTP_GET, request->url().c_str(), 404, millis() - startMs); + sendError(request, 404, "log not found"); + return; + } + + logger.logRequest(HTTP_GET, request->url().c_str(), 200, millis() - startMs); + + // Stream log content via chunked response — reader handles decompression + AsyncWebServerResponse *response = request->beginChunkedResponse( + "application/x-ndjson", + [reader](uint8_t *buffer, size_t maxLen, size_t) -> size_t { return reader->read(buffer, maxLen); }); + + response->addHeader("Content-Disposition", "attachment; filename=\"" + downloadName(filename) + "\""); + + request->send(response); + }); + + // DELETE /api/logs[/filename] — delete all logs or a specific file + loggedRoute("/api/logs", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { + String filename = request->url().substring(String("/api/logs/").length()); + + if (filename.isEmpty()) { + logger.deleteAllLogs(); + sendOk(request); + return 200; + } + + if (logger.deleteLog(filename)) { + sendOk(request); + return 200; + } + + sendError(request, 404, "log not found"); + return 404; + }); + + LOG("WEB", "Log routes registered"); +} + +// -- System health endpoint --------------------------------------------------- + +void WebServer::registerSystemRoutes() { + + // GET /api/system — live system health (heap, uptime, RSSI, storage, NTP) + loggedRoute("/api/system", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { + request->send(200, "application/json", sysMgr.getSystemHealth(settingsMgr.get().tz).toJson()); + return 200; + }); + + // POST /api/system/restart — deferred restart + loggedRoute("/api/system/restart", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { + sendOk(request); + sysMgr.restart(); + return 200; + }); + + // POST /api/system/reset — factory reset (clears NVS + WiFi, then restarts) + loggedRoute("/api/system/reset", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { + sendOk(request); + sysMgr.factoryReset(); + return 200; + }); + + // POST /api/system/format-fs — format filesystem (erases logs + map data, then restarts) + loggedRoute("/api/system/format-fs", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { + sendOk(request); + sysMgr.formatFs(); + return 200; + }); + + LOG("WEB", "System routes registered"); +} + +// -- Settings endpoint ------------------------------------------------------- + +void WebServer::registerSettingsRoutes() { + + // GET /api/settings — all user-configurable settings + loggedRoute("/api/settings", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { + request->send(200, "application/json", settingsMgr.get().toJson()); + return 200; + }); + + // PUT /api/settings — partial update (only fields present are written) + loggedBodyRoute("/api/settings", HTTP_PUT, + [this](AsyncWebServerRequest *request, uint8_t *data, size_t len) -> int { + String body = String(reinterpret_cast(data), len); + ApplyResult result = settingsMgr.apply(body); + if (result == APPLY_INVALID) { + sendError(request, 400, "Invalid settings"); + return 400; + } + if (result == APPLY_CHANGED) { + // Push manual clean settings to manager (no reboot needed) + const auto& s = settingsMgr.get(); + manualMgr.setStallThreshold(s.stallThreshold); + manualMgr.setBrushRpm(s.brushRpm); + manualMgr.setVacuumSpeed(s.vacuumSpeed); + manualMgr.setSideBrushPower(s.sideBrushPower); + } + request->send(200, "application/json", settingsMgr.get().toJson()); + return 200; + }); + + // POST /api/notifications/test?topic= — send a test notification + loggedRoute("/api/notifications/test", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { + if (!request->hasParam("topic")) { + sendError(request, 400, "missing topic"); + return 400; + } + String topic = request->getParam("topic")->value(); + if (topic.isEmpty()) { + sendError(request, 400, "topic cannot be empty"); + return 400; + } + notifMgr.sendTestNotification(topic); + sendOk(request); + return 200; + }); + + LOG("WEB", "Settings routes registered"); +} + +// -- Firmware endpoints ------------------------------------------------------- + +void WebServer::registerFirmwareRoutes() { + + // GET /api/firmware/version — current ESP32 firmware version + chip model + robot support status + loggedRoute("/api/firmware/version", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { + std::vector fields = { + {"name", "OpenNeato", FIELD_STRING}, + {"version", fwMgr.getFirmwareVersion(), FIELD_STRING}, + {"chip", fwMgr.getChipModel(), FIELD_STRING}, + {"model", neato.getModelName(), FIELD_STRING}, + {"hostname", settingsMgr.get().hostname, FIELD_STRING}, + {"supported", isSupportedModel(neato.getModelName()) ? "true" : "false", FIELD_BOOL}, + {"identifying", neato.isIdentifying() ? "true" : "false", FIELD_BOOL}, + {"repositoryUrl", "https://github.com/renjfk/OpenNeato", FIELD_STRING}, + {"license", "MIT", FIELD_STRING}, + }; + request->send(200, "application/json", fieldsToJson(fields)); + return 200; + }); + + // POST /api/firmware/update?hash= — single-request firmware upload + server.on( + "/api/firmware/update", HTTP_POST, + // Response handler (called after upload completes) + [this](AsyncWebServerRequest *request) { + unsigned long startMs = millis(); + bool ok = fwMgr.getError().isEmpty(); + + if (ok) { + ok = fwMgr.endUpdate(); + } + + if (!ok) { + logger.logRequest(HTTP_POST, "/api/firmware/update", 400, millis() - startMs); + sendError(request, 400, fwMgr.getError()); + } else { + logger.logRequest(HTTP_POST, "/api/firmware/update", 200, millis() - startMs); + AsyncWebServerResponse *response = request->beginResponse(200, "text/plain", "OK"); + response->addHeader("Connection", "close"); + request->send(response); + } + }, + // Upload handler (called per chunk) + [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, + bool final) { + // First chunk: initialize update session + if (!index) { + String md5 = request->hasParam("hash") ? request->getParam("hash")->value() : ""; + if (!fwMgr.beginUpdate(md5)) { + return; + } + } + + if (len) { + fwMgr.writeChunk(data, len); + + // Report progress at most once per second + static unsigned long lastProgressMs = 0; + unsigned long now = millis(); + if (now - lastProgressMs >= 1000) { + auto percent = static_cast( + request->contentLength() > 0 ? static_cast(fwMgr.getProgress()) * 100.0f / + static_cast(request->contentLength()) + : 0); + LOG("FW", "Progress: %u%% (%zu/%zu bytes)", percent, fwMgr.getProgress(), + request->contentLength()); + lastProgressMs = now; + } + } + }); + + LOG("WEB", "Firmware routes registered"); +} + +// -- Map data endpoints ------------------------------------------------------- + +void WebServer::registerMapRoutes() { + + // GET /api/history[/filename] — list sessions, collection status, or download a specific file + server.on("/api/history", HTTP_GET, [this](AsyncWebServerRequest *request) { + lastApiActivity = millis(); + unsigned long startMs = lastApiActivity; + String suffix = request->url().substring(String("/api/history/").length()); + + if (suffix.isEmpty()) { + // List all session files with embedded session/summary metadata + auto sessions = historyMgr.listSessions(); + String json = "["; + for (size_t i = 0; i < sessions.size(); i++) { + if (i > 0) + json += ","; + const auto& s = sessions[i]; + json += R"({"name":")" + s.name + R"(","size":)" + String(static_cast(s.size)) + + R"(,"compressed":)" + String(s.compressed ? "true" : "false") + R"(,"recording":)" + + String(s.recording ? "true" : "false"); + if (s.session.length() > 0) { + json += ",\"session\":" + s.session; + } else { + json += ",\"session\":null"; + } + if (s.summary.length() > 0) { + json += ",\"summary\":" + s.summary; + } else { + json += ",\"summary\":null"; + } + json += "}"; + } + json += "]"; + logger.logRequest(HTTP_GET, "/api/history", 200, millis() - startMs); + request->send(200, "application/json", json); + return; + } + + // Download specific session + auto reader = historyMgr.readSession(suffix); + if (!reader) { + logger.logRequest(HTTP_GET, request->url().c_str(), 404, millis() - startMs); + sendError(request, 404, "session not found"); + return; + } + + logger.logRequest(HTTP_GET, request->url().c_str(), 200, millis() - startMs); + + AsyncWebServerResponse *response = request->beginChunkedResponse( + "application/x-ndjson", + [reader](uint8_t *buffer, size_t maxLen, size_t) -> size_t { return reader->read(buffer, maxLen); }); + + response->addHeader("Content-Disposition", "attachment; filename=\"" + downloadName(suffix) + "\""); + + request->send(response); + }); + + // DELETE /api/history[/filename] — delete one or all sessions + loggedRoute("/api/history", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { + String filename = request->url().substring(String("/api/history/").length()); + + if (filename.isEmpty()) { + historyMgr.deleteAllSessions(); + sendOk(request); + return 200; + } + + if (historyMgr.deleteSession(filename)) { + sendOk(request); + return 200; + } + + sendError(request, 404, "session not found"); + return 404; + }); + + // POST /api/history/import — upload a .jsonl session file, compress and store + server.on( + "/api/history/import", HTTP_POST, + // Response handler (called after upload completes) + [this](AsyncWebServerRequest *request) { + unsigned long startMs = millis(); + bool ok = historyMgr.getImportError().isEmpty(); + + if (ok) { + ok = historyMgr.endImport(); + } + + if (!ok) { + logger.logRequest(HTTP_POST, "/api/history/import", 400, millis() - startMs); + sendError(request, 400, historyMgr.getImportError()); + } else { + logger.logRequest(HTTP_POST, "/api/history/import", 200, millis() - startMs); + sendOk(request); + } + }, + // Upload handler (called per chunk) + [this](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, + bool final) { + // First chunk: initialize import session + if (!index) { + if (!historyMgr.beginImport(filename)) { + return; + } + } + + if (len && historyMgr.isImporting()) { + historyMgr.writeImportChunk(data, len); + } + }); + + LOG("WEB", "History routes registered"); +} + +// -- WiFi management endpoints ----------------------------------------------- + +void WebServer::registerWiFiRoutes() { + // GET /api/wifi/status , STA + fallback AP snapshot + registerGetRoute("/api/wifi/status", wifiMgr, &WiFiManager::getStatus, {}); + + // GET /api/wifi/scan , list nearby networks + registerGetRoute("/api/wifi/scan", wifiMgr, &WiFiManager::scanNetworks, {}); + + // POST /api/wifi/connect?ssid=&password= , save credentials and connect. + // On success the device reboots into normal STA mode; on failure the + // fallback AP stays up so the user can retry. + registerPostRoute("/api/wifi/connect", wifiMgr, &WiFiManager::connect, {"ssid", "password"}); + + // POST /api/wifi/disconnect , clear credentials and drop the connection + registerPostRoute("/api/wifi/disconnect", wifiMgr, &WiFiManager::disconnect, {}); + + LOG("WEB", "WiFi routes registered"); +} diff --git a/firmware/src/web_server.h b/firmware/src/web_server.h index e999f2c..db864ab 100644 --- a/firmware/src/web_server.h +++ b/firmware/src/web_server.h @@ -17,12 +17,13 @@ class SettingsManager; class ManualCleanManager; class NotificationManager; class CleaningHistory; +class WiFiManager; class WebServer { public: WebServer(AsyncWebServer& server, NeatoSerial& neato, DataLogger& logger, SystemManager& sys, FirmwareManager& fw, SettingsManager& settings, ManualCleanManager& manual, NotificationManager& notif, - CleaningHistory& history); + CleaningHistory& history, WiFiManager& wifi); void begin(); // Last time any API request was received (millis()). Any module can check @@ -39,6 +40,7 @@ class WebServer { ManualCleanManager& manualMgr; NotificationManager& notifMgr; CleaningHistory& historyMgr; + WiFiManager& wifiMgr; void registerApiRoutes(); void registerManualRoutes(); @@ -47,6 +49,7 @@ class WebServer { void registerSettingsRoutes(); void registerFirmwareRoutes(); void registerMapRoutes(); + void registerWiFiRoutes(); static void sendGzipAsset(AsyncWebServerRequest *request, const uint8_t *data, size_t len, const char *contentType); static void sendError(AsyncWebServerRequest *request, int code, const String& msg); static void sendOk(AsyncWebServerRequest *request); diff --git a/firmware/src/wifi_manager.cpp b/firmware/src/wifi_manager.cpp index b2bb1cb..93b3ed5 100644 --- a/firmware/src/wifi_manager.cpp +++ b/firmware/src/wifi_manager.cpp @@ -1,8 +1,47 @@ #include "wifi_manager.h" #include "data_logger.h" +#include #include #include +// -- Helper structs ---------------------------------------------------------- + +std::vector WiFiStatus::toFields() const { + return { + {"staConnected", staConnected ? "true" : "false", FIELD_BOOL}, + {"ssid", ssid, FIELD_STRING}, + {"ip", ip, FIELD_STRING}, + {"rssi", String(rssi), FIELD_INT}, + {"apActive", apActive ? "true" : "false", FIELD_BOOL}, + {"apSsid", apSsid, FIELD_STRING}, + {"apIp", apIp, FIELD_STRING}, + {"apClients", String(apClients), FIELD_INT}, + {"apFallbackOnDisconnect", apFallbackOnDisconnect ? "true" : "false", FIELD_BOOL}, + {"lastError", lastError, FIELD_STRING}, + }; +} + +std::vector WiFiNetwork::toFields() const { + return { + {"ssid", ssid, FIELD_STRING}, + {"rssi", String(rssi), FIELD_INT}, + {"open", open ? "true" : "false", FIELD_BOOL}, + }; +} + +String WiFiScanResult::toJson() const { + String json = "{\"networks\":["; + for (size_t i = 0; i < networks.size(); i++) { + if (i > 0) + json += ","; + json += networks[i].toJson(); + } + json += "]}"; + return json; +} + +// -- WiFiManager ------------------------------------------------------------- + WiFiManager::WiFiManager(Preferences& prefs, DataLogger& logger) : LoopTask(WIFI_RECONNECT_INTERVAL), prefs(prefs), dataLogger(logger), menu("WiFi Configuration Menu"), networkMenu("Available WiFi Networks") {} @@ -28,6 +67,7 @@ void WiFiManager::begin() { LOG("WIFI", "Connected successfully!"); LOG("WIFI", "IP: %s", WiFi.localIP().toString().c_str()); LOG("WIFI", "MAC: %s", WiFi.macAddress().c_str()); + startMdns(); // Log successful boot connection with diagnostics dataLogger.logWifi("boot_connect", {{"ssid", ssid, FIELD_STRING}, @@ -39,6 +79,7 @@ void WiFiManager::begin() { return; } LOG("WIFI", "Failed to connect with saved credentials"); + lastStaError = wifiStatusReason(WiFi.status()); // Log boot connection failure dataLogger.logWifi("boot_connect_fail", @@ -50,6 +91,9 @@ void WiFiManager::begin() { // No credentials or connection failed LOG("WIFI", "WiFi not configured!"); inConfigMode = true; + + // Bring up the fallback AP so users can provision via browser + reevaluateFallbackAp(); } void WiFiManager::showMenu() { @@ -58,7 +102,7 @@ void WiFiManager::showMenu() { // Build menu items menu.clearItems(); - menu.addItem("Scan WiFi networks", "Scan and select from available networks", [this]() { scanNetworks(); }); + menu.addItem("Scan WiFi networks", "Scan and select from available networks", [this]() { scanNetworksMenu(); }); menu.addItem("Enter SSID manually", "Type network name manually", [this]() { manualSSID(); }); menu.addItem("Show current status", "Display WiFi connection status", [this]() { showStatus(); }); menu.addItem("Reset all settings", "Erase all saved settings and restart", [this]() { resetCredentials(); }); @@ -104,6 +148,10 @@ void WiFiManager::handleSerialInput() { if (loadCredentials(ssid, pass)) { menu.printKeyValue("Saved SSID", ssid); } + if (apActive) { + menu.printKeyValue("Fallback AP", apSsidName()); + menu.printKeyValue("AP IP", WiFi.softAPIP().toString()); + } menu.printSeparator(); menu.printStatus("Quick commands: [m]enu, [s]tatus"); } else if (c != '\n' && c != '\r') { @@ -116,7 +164,7 @@ void WiFiManager::handleSerialInput() { } } -void WiFiManager::scanNetworks() { +void WiFiManager::scanNetworksMenu() { menu.printStatus("Scanning WiFi networks..."); scannedNetworkCount = WiFi.scanNetworks(); @@ -256,6 +304,12 @@ void WiFiManager::showStatus() { menu.printKeyValue("Saved SSID", ssid); } + if (apActive) { + menu.printKeyValue("Fallback AP", apSsidName()); + menu.printKeyValue("AP IP", WiFi.softAPIP().toString()); + menu.printKeyValue("AP clients", String(WiFi.softAPgetStationNum())); + } + menu.printSeparator(); menu.printStatus(""); // Print newline @@ -283,7 +337,9 @@ bool WiFiManager::connectToWiFi(const String& ssid, const String& password) { delay(100); WiFi.setHostname(hostname.c_str()); - WiFi.mode(WIFI_STA); + // Use AP+STA mode if the fallback AP is currently active so we keep + // serving the provisioning page during the connection attempt. + WiFi.mode(apActive ? WIFI_AP_STA : WIFI_STA); // Enable modem sleep — radio powers down between AP beacons (~100ms), // reducing idle current from ~120mA to ~15-20mA. WiFi association stays // active; AP buffers frames during sleep. TX power is unaffected. @@ -310,8 +366,10 @@ bool WiFiManager::connectToWiFi(const String& ssid, const String& password) { wasConnected = true; reconnectBackoff = WIFI_RECONNECT_INTERVAL; reconnectAttemptCount = 0; + lastStaError = ""; } else { LOG("WIFI", "Connect failed after %lu ms (%d attempts), status=%d", connectMs, attempts, WiFi.status()); + lastStaError = wifiStatusReason(WiFi.status()); // Enable auto-reconnect even if boot connect timed out (e.g. slow DHCP). // WiFi may still be associating in the background — let loop() handle // reconnection so the deferred web server start can pick it up. @@ -332,7 +390,16 @@ void WiFiManager::setTxPower(int quarterDbm) { LOG("WIFI", "TX power updated to %.1f dBm", quarterDbm * 0.25f); } +void WiFiManager::setApFallbackOnDisconnect(bool enabled) { + apFallbackOnDisconnect = enabled; + reevaluateFallbackAp(); +} + void WiFiManager::tick() { + // Re-evaluate the AP regardless of STA state , this also handles the + // recovery case where credentials are wiped via the API. + reevaluateFallbackAp(); + // Only attempt auto-reconnect if we were previously connected and are not // in config mode (user is actively setting up WiFi through the serial menu) if (inConfigMode || !wasConnected || WiFi.status() == WL_CONNECTED) @@ -362,6 +429,7 @@ void WiFiManager::tick() { if (WiFi.status() == WL_CONNECTED) { applyTxPower(); + startMdns(); LOG("WIFI", "Reconnected! IP: %s, RSSI: %d, attempt: %lu", WiFi.localIP().toString().c_str(), WiFi.RSSI(), reconnectAttemptCount); @@ -375,11 +443,13 @@ void WiFiManager::tick() { reconnectBackoff = WIFI_RECONNECT_INTERVAL; // Reset backoff on success setInterval(reconnectBackoff); reconnectAttemptCount = 0; + lastStaError = ""; } else { // Exponential backoff: 5s -> 10s -> 20s -> 30s (capped) reconnectBackoff = min(reconnectBackoff * 2, static_cast(WIFI_MAX_RECONNECT_BACKOFF)); setInterval(reconnectBackoff); LOG("WIFI", "Reconnect failed (status=%d), next attempt in %lu ms", WiFi.status(), reconnectBackoff); + lastStaError = wifiStatusReason(WiFi.status()); dataLogger.logWifi("reconnect_fail", {{"ssid", ssid, FIELD_STRING}, {"status", String(WiFi.status()), FIELD_INT}, @@ -424,6 +494,204 @@ bool WiFiManager::loadCredentials(String& ssid, String& password) { return ssid.length() > 0; } +bool WiFiManager::hasSavedCredentials() { + String ssid, password; + return loadCredentials(ssid, password); +} + bool WiFiManager::isConnected() const { return WiFi.status() == WL_CONNECTED; } + +// -- Fallback AP ------------------------------------------------------------- + +String WiFiManager::apSsidName() const { + return hostname + AP_SSID_SUFFIX; +} + +void WiFiManager::startAccessPoint() { + if (apActive) + return; + + String ssid = apSsidName(); + LOG("WIFI", "Starting fallback AP: %s", ssid.c_str()); + + // Combined AP+STA so the device can keep trying STA reconnects while the + // AP is up. Pure-AP mode would block reconnect attempts entirely. + WiFi.mode(WIFI_AP_STA); + WiFi.softAPConfig(AP_DEFAULT_IP, AP_GATEWAY, AP_SUBNET); + bool ok = WiFi.softAP(ssid.c_str(), nullptr, AP_CHANNEL, /*ssid_hidden=*/0, AP_MAX_CONNECTIONS); + if (!ok) { + LOG("WIFI", "softAP() failed"); + dataLogger.logWifi("ap_start_fail", {{"ssid", ssid, FIELD_STRING}}); + return; + } + + apActive = true; + LOG("WIFI", "AP active: SSID=%s IP=%s", ssid.c_str(), WiFi.softAPIP().toString().c_str()); + dataLogger.logWifi("ap_start", {{"ssid", ssid, FIELD_STRING}, {"ip", WiFi.softAPIP().toString(), FIELD_STRING}}); + startMdns(); +} + +void WiFiManager::stopAccessPoint() { + if (!apActive) + return; + + LOG("WIFI", "Stopping fallback AP"); + WiFi.softAPdisconnect(true); + // If STA is still wanted, keep STA mode active; otherwise drop to STA-only + // so the radio doesn't sit in AP+STA with no AP. + WiFi.mode(WIFI_STA); + apActive = false; + dataLogger.logWifi("ap_stop"); +} + +void WiFiManager::startMdns() { + if (hostname.isEmpty()) { + LOG("WIFI", "mDNS init skipped: empty hostname"); + return; + } + + // Restart mDNS so service registration stays consistent when the active + // interface changes (STA<->AP) and this method is called repeatedly. + MDNS.end(); + + if (!MDNS.begin(hostname.c_str())) { + LOG("WIFI", "mDNS init failed for hostname: %s", hostname.c_str()); + return; + } + + if (!MDNS.addService("http", "tcp", 80)) { + LOG("WIFI", "mDNS HTTP service registration failed"); + return; + } + + LOG("WIFI", "mDNS responder up: %s.local", hostname.c_str()); +} + +void WiFiManager::reevaluateFallbackAp() { + // Three policies, derived from the issue scope: + // 1. No saved credentials -> AP always on (only way to provision). + // 2. STA connected -> AP off. + // 3. Saved credentials but STA disconnected -> AP on iff + // apFallbackOnDisconnect is true. + bool credsSaved = hasSavedCredentials(); + bool staConnected = WiFi.status() == WL_CONNECTED; + + bool wantAp; + if (!credsSaved) { + wantAp = true; + } else if (staConnected) { + wantAp = false; + } else { + wantAp = apFallbackOnDisconnect; + } + + if (wantAp && !apActive) { + startAccessPoint(); + } else if (!wantAp && apActive) { + stopAccessPoint(); + } +} + +// -- API surface ------------------------------------------------------------- + +void WiFiManager::getStatus(std::function cb) { + WiFiStatus s; + s.staConnected = isConnected(); + if (s.staConnected) { + s.ssid = WiFi.SSID(); + s.ip = WiFi.localIP().toString(); + s.rssi = WiFi.RSSI(); + } else { + // Fall back to saved SSID so the UI can show "trying to reconnect to X" + String savedPass; + loadCredentials(s.ssid, savedPass); + } + s.apActive = apActive; + if (apActive) { + s.apSsid = apSsidName(); + s.apIp = WiFi.softAPIP().toString(); + s.apClients = WiFi.softAPgetStationNum(); + } + s.apFallbackOnDisconnect = apFallbackOnDisconnect; + s.lastError = lastStaError; + cb(true, s); +} + +void WiFiManager::scanNetworks(std::function cb) { + WiFiScanResult result; + + // Use synchronous scan with watchdog feeding so a slow scan doesn't + // trigger a TWDT reset. Returns the network count or a negative error. + int n = WiFi.scanNetworks(/*async=*/false, /*show_hidden=*/false); + esp_task_wdt_reset(); + + if (n < 0) { + LOG("WIFI", "Scan failed: %d", n); + cb(false, result); + return; + } + + // Cap at 30 entries , anything more than that on an ESP32-C3 is noise + // and would just bloat the JSON response. + int max = n < 30 ? n : 30; + result.networks.reserve(max); + for (int i = 0; i < max; ++i) { + WiFiNetwork net; + net.ssid = WiFi.SSID(i); + net.rssi = WiFi.RSSI(i); + net.open = WiFi.encryptionType(i) == WIFI_AUTH_OPEN; + result.networks.push_back(net); + } + WiFi.scanDelete(); + + cb(true, result); +} + +bool WiFiManager::connect(const String& ssid, const String& password, std::function cb) { + if (ssid.isEmpty()) { + cb(false); + return true; // Handler ran (returned an error) , don't return 503 + } + + LOG("WIFI", "API connect request: %s", ssid.c_str()); + dataLogger.logWifi("api_connect", {{"ssid", ssid, FIELD_STRING}}); + + // Save first so a successful connection leads to a normal reboot path + // and a failure can be inspected via the AP afterwards. + saveCredentials(ssid, password); + + bool ok = connectToWiFi(ssid, password); + if (ok) { + // Schedule a reboot so the regular boot flow brings up the web server, + // mDNS, etc. with the freshly connected STA. Give the response time + // to flush first. + cb(true); + delay(500); + ESP.restart(); + return true; + } + + // Connection failed , keep AP up so the user can try again. + reevaluateFallbackAp(); + cb(false); + return true; +} + +bool WiFiManager::disconnect(std::function cb) { + LOG("WIFI", "API disconnect request"); + dataLogger.logWifi("api_disconnect"); + + prefs.remove(NVS_KEY_WIFI_SSID); + prefs.remove(NVS_KEY_WIFI_PASS); + WiFi.disconnect(true, true); + wasConnected = false; + lastStaError = ""; + + // No credentials -> AP must be up regardless of policy + reevaluateFallbackAp(); + + cb(true); + return true; +} diff --git a/firmware/src/wifi_manager.h b/firmware/src/wifi_manager.h index 17de563..a74e429 100644 --- a/firmware/src/wifi_manager.h +++ b/firmware/src/wifi_manager.h @@ -3,12 +3,48 @@ #include #include +#include +#include #include "config.h" +#include "json_fields.h" #include "loop_task.h" #include "serial_menu.h" class DataLogger; +// Status of an STA connection attempt initiated via the API. +struct WiFiStatus : public JsonSerializable { + bool staConnected = false; + String ssid; + String ip; + int rssi = 0; + bool apActive = false; + String apSsid; + String apIp; + int apClients = 0; + bool apFallbackOnDisconnect = true; + String lastError; // Empty when no error; otherwise human-readable failure reason + + std::vector toFields() const override; +}; + +// Single network result from a scan. +struct WiFiNetwork : public JsonSerializable { + String ssid; + int rssi = 0; + bool open = false; + + std::vector toFields() const override; +}; + +// Scan result list , wraps a vector of networks for JSON serialization. +struct WiFiScanResult : public JsonSerializable { + std::vector networks; + + std::vector toFields() const override { return {}; } // Unused , toJson is overridden + String toJson() const; +}; + class WiFiManager : public LoopTask { public: WiFiManager(Preferences& prefs, DataLogger& logger); @@ -21,12 +57,38 @@ class WiFiManager : public LoopTask { bool isConnected() const; + // True when the fallback AP is currently broadcasting. Used by main.cpp + // to decide whether the web server should come up even without STA. + bool isApActive() const { return apActive; } + // Set hostname for WiFi/mDNS. Must be called before begin() or takes effect on next reboot. void setHostname(const String& name) { hostname = name; } // Apply TX power setting (0.25 dBm units). Safe to call at any time. void setTxPower(int quarterDbm); + // Update the fallback-on-disconnect policy and re-evaluate AP state. + void setApFallbackOnDisconnect(bool enabled); + + // -- API-driven WiFi management ------------------------------------------ + // These methods follow the registerGetRoute / registerPostRoute callback + // shape so they can be wired directly from the web server. + + // Snapshot current STA + AP state. Always succeeds. + void getStatus(std::function cb); + + // Trigger a synchronous WiFi.scanNetworks() and report results. + // Blocks the calling task for ~2s; only invoked from API handlers. + void scanNetworks(std::function cb); + + // Save credentials, attempt connection. Reboots on success so the + // new STA setup goes through the regular boot path. + bool connect(const String& ssid, const String& password, std::function cb); + + // Clear saved credentials and disconnect the STA. AP comes up + // automatically after the disconnect (no credentials saved). + bool disconnect(std::function cb); + private: Preferences& prefs; DataLogger& dataLogger; @@ -38,6 +100,11 @@ class WiFiManager : public LoopTask { String selectedSSID = ""; int scannedNetworkCount = 0; + // Fallback AP state + bool apActive = false; + bool apFallbackOnDisconnect = true; // Mirrors setting; updated via setApFallbackOnDisconnect + String lastStaError; // Last STA failure reason (cleared on successful connect) + void tick() override; // Called by LoopTask::loop() at WIFI_RECONNECT_INTERVAL cadence // Apply TX power from NVS (called after WiFi.begin and after reconnect) @@ -54,8 +121,24 @@ class WiFiManager : public LoopTask { bool loadCredentials(String& ssid, String& password); + bool hasSavedCredentials(); + + // Fallback AP lifecycle + String apSsidName() const; // Returns "-ap" + void startAccessPoint(); + void stopAccessPoint(); + // Decide whether the AP should be on and bring it up / down accordingly. + // Called whenever STA state, credentials, or apFallbackOnDisconnect change. + void reevaluateFallbackAp(); + + // Safe to call repeatedly: bring up the mDNS responder for + // .local advertising + // the web UI on port 80. Called whenever a network interface (STA or AP) + // becomes available so clients can reach the device without knowing its IP. + void startMdns(); + // Menu actions - void scanNetworks(); + void scanNetworksMenu(); void manualSSID(); void showStatus(); void resetCredentials(); diff --git a/flash/go.mod b/flash/go.mod index fbb3bc3..b8aee35 100644 --- a/flash/go.mod +++ b/flash/go.mod @@ -1,13 +1,13 @@ module github.com/renjfk/OpenNeato/flash -go 1.26.1 +go 1.26.2 require ( go.bug.st/serial v1.6.4 - golang.org/x/term v0.41.0 + golang.org/x/term v0.43.0 ) require ( - github.com/creack/goselect v0.1.2 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/creack/goselect v0.1.3 // indirect + golang.org/x/sys v0.44.0 // indirect ) diff --git a/flash/go.sum b/flash/go.sum index 5acd17d..e98a5b8 100644 --- a/flash/go.sum +++ b/flash/go.sum @@ -1,5 +1,5 @@ -github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= -github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= +github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -8,9 +8,9 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/frontend/.jscpd.json b/frontend/.jscpd.json new file mode 100644 index 0000000..458d0e8 --- /dev/null +++ b/frontend/.jscpd.json @@ -0,0 +1,5 @@ +{ + "threshold": 1.1, + "minLines": 5, + "minTokens": 75 +} diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/frontend/api/openapi.yaml b/frontend/api/openapi.yaml new file mode 100644 index 0000000..9aa7357 --- /dev/null +++ b/frontend/api/openapi.yaml @@ -0,0 +1,1839 @@ +openapi: 3.0.3 +info: + title: OpenNeato API + version: 0.0.0-dev + description: HTTP API exposed by the OpenNeato firmware. All endpoints are served on the local network only (no authentication, no TLS). The diagnostic `/api/serial` passthrough is documented separately in the [user guide](https://github.com/renjfk/OpenNeato/blob/main/docs/user-guide.md#serial-api). +servers: + - url: http://{host} + description: OpenNeato bridge on local network + variables: + host: + default: neato.local +tags: + - name: Sensors + description: Read-only telemetry from the robot + - name: Actions + description: Control commands sent to the robot + - name: Manual + description: Manual cleaning mode (joystick, motors) + - name: Logs + description: Diagnostic log files stored in flash + - name: WiFi + description: WiFi station status and provisioning over the fallback AP + - name: System + description: ESP32 system health and lifecycle + - name: Settings + description: User-configurable bridge settings + - name: Firmware + description: ESP32 firmware version and OTA update + - name: History + description: Cleaning session history and map data +paths: + /api/version: + get: + tags: + - Sensors + summary: Get robot identity (model, serial, firmware versions) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/VersionData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/charger: + get: + tags: + - Sensors + summary: Get battery and charger status + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ChargerData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/analog: + get: + tags: + - Sensors + summary: Get battery-related analog readings + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BatteryAnalogData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/warranty: + get: + tags: + - Sensors + summary: Get battery cycle and lifetime counters + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/BatteryWarrantyData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/battery/new: + post: + tags: + - Actions + summary: Tell the robot a new battery has been installed (handles TestMode sequence internally) + responses: + "200": + description: Success acknowledgement + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/motors: + get: + tags: + - Sensors + summary: Get motor telemetry (brush, vacuum, wheels, side brush, laser) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/MotorData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/sensors: + get: + tags: + - Sensors + summary: Get digital sensor data (dustbin, bumpers, wheel lift, DC jack) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/DigitalSensorData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/state: + get: + tags: + - Sensors + summary: Get current UI and robot state machine values + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/StateData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/error: + get: + tags: + - Sensors + summary: Get current error or warning state + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/lidar: + get: + tags: + - Sensors + summary: Get latest 360-point LIDAR scan + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/LidarScan" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/user-settings: + get: + tags: + - Sensors + summary: Get robot on-board user settings (sounds, eco, wall follow, maintenance) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/UserSettingsData" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + tags: + - Actions + summary: Set a single robot on-board user setting + parameters: + - name: key + in: query + required: true + schema: + type: string + - name: value + in: query + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: robot busy + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/clean: + post: + tags: + - Actions + summary: Start, pause, resume, stop, or dock cleaning + parameters: + - name: action + in: query + required: true + schema: + type: string + enum: + - house + - spot + - pause + - stop + - dock + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: robot busy + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/sound: + post: + tags: + - Actions + summary: Play a robot sound effect + parameters: + - name: id + in: query + required: true + schema: + type: integer + minimum: 0 + maximum: 20 + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: robot busy + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/power: + post: + tags: + - Actions + summary: Restart or shutdown the robot + parameters: + - name: action + in: query + required: true + schema: + type: string + enum: + - restart + - shutdown + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: robot busy + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/lidar/rotate: + post: + tags: + - Actions + summary: Start or stop LIDAR turret rotation + parameters: + - name: enable + in: query + required: true + schema: + type: boolean + minimum: 0 + maximum: 1 + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: robot busy + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/clear-errors: + post: + tags: + - Actions + summary: Clear all UI errors and warnings on the robot + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: robot busy + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/manual/status: + get: + tags: + - Manual + summary: Get manual mode state (active flag, motors, bumpers, stalls) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ManualStatus" + /api/manual/move: + post: + tags: + - Manual + summary: Drive the wheels for a specific distance at a given speed + parameters: + - name: left + in: query + required: true + description: mm, negative=backward + schema: + type: integer + - name: right + in: query + required: true + description: mm, negative=backward + schema: + type: integer + - name: speed + in: query + required: true + description: mm/s + schema: + type: integer + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: manual mode inactive or unsafe state + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/manual/motors: + post: + tags: + - Manual + summary: Toggle main brush, vacuum, and side brush motors + parameters: + - name: brush + in: query + required: true + schema: + type: boolean + minimum: 0 + maximum: 1 + - name: vacuum + in: query + required: true + schema: + type: boolean + minimum: 0 + maximum: 1 + - name: sideBrush + in: query + required: true + schema: + type: boolean + minimum: 0 + maximum: 1 + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: manual mode inactive + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/manual: + post: + tags: + - Manual + summary: Enable or disable manual mode (also enters TestMode and starts LIDAR) + parameters: + - name: enable + in: query + required: true + schema: + type: boolean + minimum: 0 + maximum: 1 + responses: + "200": + $ref: "#/components/responses/Ok" + "503": + description: cannot enter manual mode + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "504": + description: robot timeout + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/logs: + get: + tags: + - Logs + summary: List all log files + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/LogFileInfo" + delete: + tags: + - Logs + summary: Delete all log files + responses: + "200": + $ref: "#/components/responses/Ok" + /api/logs/{filename}: + get: + tags: + - Logs + summary: Download a single log file (transparently decompressed) + parameters: + - name: filename + in: path + required: true + schema: + type: string + responses: + "200": + description: application/x-ndjson + content: + application/x-ndjson: + schema: + type: string + "404": + description: log not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + tags: + - Logs + summary: Delete a single log file + parameters: + - name: filename + in: path + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/Ok" + "404": + description: log not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/wifi/status: + get: + tags: + - WiFi + summary: Get current WiFi STA + fallback AP status snapshot + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/WiFiStatus" + /api/wifi/scan: + get: + tags: + - WiFi + summary: Scan for nearby WiFi networks (synchronous, capped at 30 entries) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/WiFiScanResult" + "500": + description: scan failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/wifi/connect: + post: + tags: + - WiFi + summary: Save credentials and connect to a WiFi network (reboots on success) + parameters: + - name: ssid + in: query + required: true + schema: + type: string + - name: password + in: query + required: true + description: Empty string for open networks + schema: + type: string + responses: + "200": + $ref: "#/components/responses/Ok" + "400": + description: missing ssid or connection failed + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/wifi/disconnect: + post: + tags: + - WiFi + summary: Clear saved credentials and drop the STA connection + responses: + "200": + $ref: "#/components/responses/Ok" + /api/system: + get: + tags: + - System + summary: Get live system metrics (heap, uptime, WiFi RSSI, storage, NTP, time) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/SystemData" + /api/system/restart: + post: + tags: + - System + summary: Restart the ESP32 (deferred 500ms to flush HTTP response) + responses: + "200": + $ref: "#/components/responses/Ok" + /api/system/reset: + post: + tags: + - System + summary: Factory reset (clear NVS and WiFi credentials, then restart) + responses: + "200": + $ref: "#/components/responses/Ok" + /api/system/format-fs: + post: + tags: + - System + summary: Format the SPIFFS filesystem (erases logs and history, then restart) + responses: + "200": + $ref: "#/components/responses/Ok" + /api/settings: + get: + tags: + - Settings + summary: Get all bridge settings (timezone, logging, WiFi, navigation, schedule, notifications) + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsData" + put: + tags: + - Settings + summary: Partial settings update (only fields present in body are written) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsData" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsData" + "400": + description: invalid settings + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/notifications/test: + post: + tags: + - Settings + summary: Send a test push notification to the given ntfy.sh topic + parameters: + - name: topic + in: query + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/Ok" + "400": + description: missing or empty topic + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/firmware/version: + get: + tags: + - Firmware + summary: Get current ESP32 firmware version, chip model, robot model, and support status + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/FirmwareVersion" + /api/firmware/update: + post: + tags: + - Firmware + summary: Upload a new firmware image and verify against the supplied MD5 + parameters: + - name: hash + in: query + required: true + description: MD5 of firmware binary + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + responses: + "200": + description: text/plain "OK" + content: + text/plain: + schema: + type: string + "400": + description: update failed (bad hash, write error, or MD5 mismatch) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/history: + get: + tags: + - History + summary: List all cleaning session files with embedded session and summary metadata + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/HistoryFileInfo" + delete: + tags: + - History + summary: Delete all cleaning session files + responses: + "200": + $ref: "#/components/responses/Ok" + /api/history/{filename}: + get: + tags: + - History + summary: Download a single session file (transparently decompressed) + parameters: + - name: filename + in: path + required: true + schema: + type: string + responses: + "200": + description: application/x-ndjson + content: + application/x-ndjson: + schema: + type: string + "404": + description: session not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + tags: + - History + summary: Delete a single cleaning session file + parameters: + - name: filename + in: path + required: true + schema: + type: string + responses: + "200": + $ref: "#/components/responses/Ok" + "404": + description: session not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /api/history/import: + post: + tags: + - History + summary: Upload a JSONL session file (compressed and stored on flash) + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + responses: + "200": + $ref: "#/components/responses/Ok" + "400": + description: import failed (invalid file or write error) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Ok: + type: object + properties: + ok: + type: boolean + required: + - ok + Error: + type: object + properties: + error: + type: string + required: + - error + VersionData: + type: object + properties: + modelName: + type: string + description: Robot model name (e.g. "Botvac D7") + serialNumber: + type: string + description: Robot serial number + softwareVersion: + type: string + description: Robot firmware version, format "Major.Minor.Build" + ldsVersion: + type: string + description: LIDAR firmware version + ldsSerial: + type: string + description: LIDAR serial number + mainBoardVersion: + type: string + description: Main board hardware version + smartBatteryAuthorization: + type: number + description: Smart battery authorization status reported by the robot + smartBatteryDataVersion: + type: number + description: Smart battery data block version + smartBatteryChemistry: + type: string + description: Battery chemistry, e.g. LION1 + smartBatteryDeviceName: + type: string + description: Smart battery device name + smartBatteryManufacturerName: + type: string + description: Smart battery manufacturer name + smartBatteryMfgDate: + type: string + description: Smart battery manufacturing date as reported by the robot + smartBatterySerialNumber: + type: string + description: Smart battery serial number + smartBatterySoftwareVersion: + type: string + description: Smart battery controller software version + required: + - modelName + - serialNumber + - softwareVersion + - ldsVersion + - ldsSerial + - mainBoardVersion + ChargerData: + type: object + properties: + fuelPercent: + type: number + description: Battery charge percentage (0-100, -1 = unknown) + batteryOverTemp: + type: boolean + description: Battery temperature exceeds safe limits + chargingActive: + type: boolean + description: Charger currently delivering current + chargingEnabled: + type: boolean + description: Charging circuit enabled (independent of active flow) + confidOnFuel: + type: boolean + description: Fuel gauge reading is trusted + onReservedFuel: + type: boolean + description: Battery has dropped into reserve range + emptyFuel: + type: boolean + description: Battery considered empty (robot will not start) + batteryFailure: + type: boolean + description: Battery hardware fault detected + extPwrPresent: + type: boolean + description: External power supply (dock or barrel jack) connected + vBattV: + type: number + description: Battery voltage in volts + vExtV: + type: number + description: External supply voltage in volts + chargerMAH: + type: number + description: Cumulative mAh delivered into the battery + dischargeMAH: + type: number + description: Cumulative mAh discharged from the battery + required: + - fuelPercent + - batteryOverTemp + - chargingActive + - chargingEnabled + - confidOnFuel + - onReservedFuel + - emptyFuel + - batteryFailure + - extPwrPresent + - vBattV + - vExtV + - chargerMAH + - dischargeMAH + BatteryAnalogData: + type: object + properties: + batteryVoltageV: + type: number + description: Battery pack voltage in volts from GetAnalogSensors + batteryCurrentMA: + type: number + description: Live battery current in mA from GetAnalogSensors + batteryTemperatureC: + type: number + description: Battery temperature in Celsius from GetAnalogSensors + externalVoltageV: + type: number + description: External supply voltage in volts from GetAnalogSensors + required: + - batteryVoltageV + - batteryCurrentMA + - batteryTemperatureC + - externalVoltageV + BatteryWarrantyData: + type: object + properties: + cumulativeBatteryCycles: + type: number + description: Lifetime battery cycles decoded from GetWarranty + cumulativeCleaningTimeSeconds: + type: number + description: Lifetime cleaning time decoded from GetWarranty + validationCode: + type: string + description: Validation code reported by GetWarranty + required: + - cumulativeBatteryCycles + - cumulativeCleaningTimeSeconds + - validationCode + DigitalSensorData: + type: object + properties: + dcJackIn: + type: boolean + description: True when the external DC barrel jack is plugged in + dustbinIn: + type: boolean + description: True when the dustbin is seated + leftWheelExtended: + type: boolean + description: True when the left drive wheel is lifted off the ground + rightWheelExtended: + type: boolean + description: True when the right drive wheel is lifted off the ground + lSideBit: + type: boolean + description: Left side bumper triggered + lFrontBit: + type: boolean + description: Left front bumper triggered + lLdsBit: + type: boolean + description: Left LDS guard bumper triggered + rSideBit: + type: boolean + description: Right side bumper triggered + rFrontBit: + type: boolean + description: Right front bumper triggered + rLdsBit: + type: boolean + description: Right LDS guard bumper triggered + required: + - dcJackIn + - dustbinIn + - leftWheelExtended + - rightWheelExtended + - lSideBit + - lFrontBit + - lLdsBit + - rSideBit + - rFrontBit + - rLdsBit + MotorData: + type: object + properties: + brushRPM: + type: number + description: Main brush rotation speed in RPM + brushMA: + type: number + description: Main brush current draw in mA + vacuumRPM: + type: number + description: Vacuum motor speed in RPM + vacuumMA: + type: number + description: Vacuum motor current draw in mA + leftWheelRPM: + type: number + description: Left wheel rotation speed in RPM + leftWheelLoad: + type: number + description: Left wheel load percentage + leftWheelPositionMM: + type: number + description: Left wheel position in millimeters since boot + leftWheelSpeed: + type: number + description: Left wheel speed in mm/s + rightWheelRPM: + type: number + description: Right wheel rotation speed in RPM + rightWheelLoad: + type: number + description: Right wheel load percentage + rightWheelPositionMM: + type: number + description: Right wheel position in millimeters since boot + rightWheelSpeed: + type: number + description: Right wheel speed in mm/s + sideBrushMA: + type: number + description: Side brush current draw in mA + laserRPM: + type: number + description: Laser turret rotation speed in RPM + required: + - brushRPM + - brushMA + - vacuumRPM + - vacuumMA + - leftWheelRPM + - leftWheelLoad + - leftWheelPositionMM + - leftWheelSpeed + - rightWheelRPM + - rightWheelLoad + - rightWheelPositionMM + - rightWheelSpeed + - sideBrushMA + - laserRPM + StateData: + type: object + properties: + uiState: + type: string + description: UI state machine value, e.g. "UIMGR_STATE_STANDBY" + robotState: + type: string + description: Robot state machine value, e.g. "ST_C_Standby" + required: + - uiState + - robotState + ErrorData: + type: object + properties: + hasError: + type: boolean + description: True if the robot is currently reporting an error or warning + kind: + type: string + enum: + - error + - warning + description: '"error" for codes 243+, "warning" for codes 201-242' + errorCode: + type: number + description: Numeric error/warning code (200 = no error) + errorMessage: + type: string + description: Full raw response from the robot (for diagnostics) + displayMessage: + type: string + description: Human-readable message suitable for UI and notifications + required: + - hasError + - kind + - errorCode + - errorMessage + - displayMessage + LidarScan: + type: object + properties: + rotationSpeed: + type: number + description: LIDAR turret rotation speed in Hz + validPoints: + type: number + description: Count of points with error == 0 + points: + type: array + items: + $ref: "#/components/schemas/LidarPoint" + description: Always 360 entries indexed by angle + required: + - rotationSpeed + - validPoints + - points + LidarPoint: + type: object + properties: + angle: + type: number + description: Bearing in degrees (0-359, 0 = robot front) + dist: + type: number + description: Distance to target in millimeters (0 if invalid) + intensity: + type: number + description: Return signal strength (0-255) + error: + type: number + description: Per-point error code (0 = valid) + required: + - angle + - dist + - intensity + - error + UserSettingsData: + type: object + properties: + buttonClick: + type: boolean + description: Play a click sound on button presses + melodies: + type: boolean + description: Play melodies (start, finish, etc.) + warnings: + type: boolean + description: Play warning chimes + ecoMode: + type: boolean + description: Reduced power cleaning (longer runtime, lower suction) + intenseClean: + type: boolean + description: Maximum power cleaning + binFullDetect: + type: boolean + description: Stop and warn when dirt bin is full + wallEnable: + type: boolean + description: Enable wall following along walls and edges + wifi: + type: boolean + description: Robot WiFi radio enabled (separate from bridge WiFi) + stealthLed: + type: boolean + description: Dim status LEDs at night + filterChange: + type: number + description: Filter change reminder interval in seconds + brushChange: + type: number + description: Brush change reminder interval in seconds + dirtBin: + type: number + description: Dirt bin reminder interval in minutes + required: + - buttonClick + - melodies + - warnings + - ecoMode + - intenseClean + - binFullDetect + - wallEnable + - wifi + - stealthLed + - filterChange + - brushChange + - dirtBin + ManualStatus: + type: object + properties: + active: + type: boolean + description: Manual mode currently engaged + brush: + type: boolean + description: Main brush motor enabled + vacuum: + type: boolean + description: Vacuum motor enabled + sideBrush: + type: boolean + description: Side brush motor enabled + lifted: + type: boolean + description: Robot wheel-drop sensor reports the robot is off the ground + bumperFrontLeft: + type: boolean + description: Front-left bumper contacted + bumperFrontRight: + type: boolean + description: Front-right bumper contacted + bumperSideLeft: + type: boolean + description: Left side bumper contacted + bumperSideRight: + type: boolean + description: Right side bumper contacted + stallFront: + type: boolean + description: Front wheel stall detected + stallRear: + type: boolean + description: Rear wheel stall detected + required: + - active + - brush + - vacuum + - sideBrush + - lifted + - bumperFrontLeft + - bumperFrontRight + - bumperSideLeft + - bumperSideRight + - stallFront + - stallRear + LogFileInfo: + type: object + properties: + name: + type: string + description: File name as stored on flash (may include .hs compression suffix) + size: + type: number + description: File size in bytes + compressed: + type: boolean + description: True if stored with heatshrink compression + required: + - name + - size + - compressed + SystemData: + type: object + properties: + heap: + type: number + description: Free heap memory in bytes + heapTotal: + type: number + description: Total heap memory in bytes + uptime: + type: number + description: Milliseconds since boot + rssi: + type: number + description: WiFi signal strength in dBm (negative, closer to 0 = stronger) + fsUsed: + type: number + description: SPIFFS bytes used + fsTotal: + type: number + description: SPIFFS total capacity in bytes + ntpSynced: + type: boolean + description: NTP has successfully synced at least once + time: + type: number + description: Current Unix epoch time in seconds (UTC) + timeSource: + type: string + description: 'Time source: "ntp", "robot", or "boot"' + tz: + type: string + description: Configured IANA timezone (e.g. "America/New_York") + localTime: + type: string + description: DST-aware local time, e.g. "Sat 17:45:01" + isDst: + type: boolean + description: True when daylight saving time is active + required: + - heap + - heapTotal + - uptime + - rssi + - fsUsed + - fsTotal + - ntpSynced + - time + - timeSource + - tz + - localTime + - isDst + SettingsData: + type: object + properties: + tz: + type: string + description: IANA timezone identifier (e.g. "America/New_York") + logLevel: + type: number + description: 0=off, 1=info, 2=debug + apFallbackOnDisconnect: + type: boolean + description: Bring up the fallback AP automatically when STA drops + syslogEnabled: + type: boolean + description: When on, logs go to UDP syslog instead of flash + syslogIp: + type: string + description: IPv4 address of syslog receiver + wifiTxPower: + type: number + description: WiFi TX power in 0.25 dBm units (e.g. 34 = 8.5 dBm) + uartTxPin: + type: number + description: GPIO pin used for UART TX to the robot + uartRxPin: + type: number + description: GPIO pin used for UART RX from the robot + maxGpioPin: + type: number + description: Read-only - max valid GPIO for this chip (21 for C3, 39 for ESP32) + hostname: + type: string + description: mDNS hostname (e.g. "openneato") + navMode: + type: string + description: 'Navigation mode for house cleaning: "Normal", "Gentle", "Deep", "Quick"' + stallThreshold: + type: number + description: Wheel load % for stall detection (30-80) + brushRpm: + type: number + description: Main brush RPM (500-1600) + vacuumSpeed: + type: number + description: Vacuum speed % (40-100) + sideBrushPower: + type: number + description: Side brush power in mW (500-1500) + ntfyTopic: + type: string + description: ntfy.sh topic for push notifications (empty = disabled) + ntfyServer: + type: string + description: Custom ntfy server hostname (empty = ntfy.sh) + ntfyToken: + type: string + description: Access token for authenticated ntfy servers (empty = no auth) + ntfyEnabled: + type: boolean + description: Global switch - must be on for any notification to fire + ntfyOnStart: + type: boolean + description: Notify when a cleaning cycle begins + ntfyOnDone: + type: boolean + description: Notify when cleaning completes + ntfyOnError: + type: boolean + description: Notify on robot error (UI_ERROR_*, code 243+) + ntfyOnAlert: + type: boolean + description: Notify on robot alert (UI_ALERT_*, code 201-242) + ntfyOnDocking: + type: boolean + description: Notify when robot returns to base + scheduleEnabled: + type: boolean + description: Master switch for the weekly cleaning schedule + sched0Hour: + type: number + sched0Min: + type: number + sched0On: + type: boolean + sched1Hour: + type: number + sched1Min: + type: number + sched1On: + type: boolean + sched2Hour: + type: number + sched2Min: + type: number + sched2On: + type: boolean + sched3Hour: + type: number + sched3Min: + type: number + sched3On: + type: boolean + sched4Hour: + type: number + sched4Min: + type: number + sched4On: + type: boolean + sched5Hour: + type: number + sched5Min: + type: number + sched5On: + type: boolean + sched6Hour: + type: number + sched6Min: + type: number + sched6On: + type: boolean + sched0Slot1Hour: + type: number + sched0Slot1Min: + type: number + sched0Slot1On: + type: boolean + sched1Slot1Hour: + type: number + sched1Slot1Min: + type: number + sched1Slot1On: + type: boolean + sched2Slot1Hour: + type: number + sched2Slot1Min: + type: number + sched2Slot1On: + type: boolean + sched3Slot1Hour: + type: number + sched3Slot1Min: + type: number + sched3Slot1On: + type: boolean + sched4Slot1Hour: + type: number + sched4Slot1Min: + type: number + sched4Slot1On: + type: boolean + sched5Slot1Hour: + type: number + sched5Slot1Min: + type: number + sched5Slot1On: + type: boolean + sched6Slot1Hour: + type: number + sched6Slot1Min: + type: number + sched6Slot1On: + type: boolean + required: + - tz + - logLevel + - apFallbackOnDisconnect + - syslogEnabled + - syslogIp + - wifiTxPower + - uartTxPin + - uartRxPin + - maxGpioPin + - hostname + - navMode + - stallThreshold + - brushRpm + - vacuumSpeed + - sideBrushPower + - ntfyTopic + - ntfyServer + - ntfyToken + - ntfyEnabled + - ntfyOnStart + - ntfyOnDone + - ntfyOnError + - ntfyOnAlert + - ntfyOnDocking + - scheduleEnabled + - sched0Hour + - sched0Min + - sched0On + - sched1Hour + - sched1Min + - sched1On + - sched2Hour + - sched2Min + - sched2On + - sched3Hour + - sched3Min + - sched3On + - sched4Hour + - sched4Min + - sched4On + - sched5Hour + - sched5Min + - sched5On + - sched6Hour + - sched6Min + - sched6On + - sched0Slot1Hour + - sched0Slot1Min + - sched0Slot1On + - sched1Slot1Hour + - sched1Slot1Min + - sched1Slot1On + - sched2Slot1Hour + - sched2Slot1Min + - sched2Slot1On + - sched3Slot1Hour + - sched3Slot1Min + - sched3Slot1On + - sched4Slot1Hour + - sched4Slot1Min + - sched4Slot1On + - sched5Slot1Hour + - sched5Slot1Min + - sched5Slot1On + - sched6Slot1Hour + - sched6Slot1Min + - sched6Slot1On + FirmwareVersion: + type: object + properties: + name: + type: string + description: Product name + version: + type: string + description: Bridge firmware semantic version + chip: + type: string + description: ESP32 chip model (e.g. "ESP32-C3") + model: + type: string + description: Robot model name (e.g. "Botvac D7", empty until identified) + hostname: + type: string + description: mDNS hostname (e.g. "openneato") + supported: + type: boolean + description: True if the connected robot model is officially supported + identifying: + type: boolean + description: True while the bridge is still probing the robot model + repositoryUrl: + type: string + description: Project repository URL + license: + type: string + description: Project license identifier + required: + - name + - version + - chip + - model + - hostname + - supported + - identifying + - repositoryUrl + - license + HistoryFileInfo: + type: object + properties: + name: + type: string + description: File name as stored on flash (may include .hs compression suffix) + size: + type: number + description: File size in bytes + compressed: + type: boolean + description: True if stored with heatshrink compression + recording: + type: boolean + description: True if this session is currently being recorded + session: + description: Session metadata, or null if not yet written + nullable: true + allOf: + - $ref: "#/components/schemas/MapSession" + summary: + description: Session summary, or null if cleaning has not finished + nullable: true + allOf: + - $ref: "#/components/schemas/MapSummary" + required: + - name + - size + - compressed + - recording + - session + - summary + MapSession: + type: object + properties: + type: + type: string + enum: + - session + description: Always "session" - identifies the record type + mode: + type: string + description: 'Cleaning mode: "House", "Spot", or "Manual"' + time: + type: number + description: Unix epoch seconds when the session started + battery: + type: number + description: Battery percentage at session start + required: + - type + - mode + - time + - battery + MapSummary: + type: object + properties: + type: + type: string + enum: + - summary + description: Always "summary" - identifies the record type + time: + type: number + description: Unix epoch seconds when the session ended + duration: + type: number + description: Session duration in seconds + mode: + type: string + description: 'Cleaning mode: "House", "Spot", or "Manual"' + recharges: + type: number + description: Number of times the robot returned to base to recharge + snapshots: + type: number + description: Number of pose snapshots captured + distanceTraveled: + type: number + description: Total distance traveled in meters + maxDistFromOrigin: + type: number + description: Maximum distance from origin in meters + totalRotation: + type: number + description: Total rotation in degrees + areaCovered: + type: number + description: Estimated area covered in square meters + errorsDuringClean: + type: number + description: Number of errors encountered during cleaning + batteryStart: + type: number + description: Battery percentage at session start + batteryEnd: + type: number + description: Battery percentage at session end + required: + - type + - time + - duration + - mode + - recharges + - snapshots + - distanceTraveled + - maxDistFromOrigin + - totalRotation + - areaCovered + - errorsDuringClean + WiFiStatus: + type: object + properties: + staConnected: + type: boolean + description: Station mode connected to an upstream AP + ssid: + type: string + description: Connected SSID, or last saved SSID when disconnected + ip: + type: string + description: STA IPv4 address (empty when disconnected) + rssi: + type: integer + description: STA signal strength in dBm (0 when disconnected) + apActive: + type: boolean + description: Fallback AP currently broadcasting + apSsid: + type: string + description: Fallback AP SSID (empty when AP is down) + apIp: + type: string + description: Fallback AP IPv4 address (empty when AP is down) + apClients: + type: integer + description: Number of clients connected to the fallback AP + apFallbackOnDisconnect: + type: boolean + description: Whether the AP is brought up automatically when STA drops + lastError: + type: string + description: Human-readable description of the most recent STA failure + required: + - staConnected + - ssid + - ip + - rssi + - apActive + - apSsid + - apIp + - apClients + - apFallbackOnDisconnect + - lastError + WiFiNetwork: + type: object + properties: + ssid: + type: string + rssi: + type: integer + description: Signal strength in dBm + open: + type: boolean + description: True for open networks (no encryption) + required: + - ssid + - rssi + - open + WiFiScanResult: + type: object + properties: + networks: + type: array + items: + $ref: "#/components/schemas/WiFiNetwork" + required: + - networks + responses: + Ok: + description: Success acknowledgement + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" diff --git a/frontend/biome.json b/frontend/biome.json index 928bf20..348d0f8 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,9 +1,10 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", "files": { "includes": [ "src/**", + "!src/types.generated.ts", "mock/*.js", "scripts/**", "vite.config.ts", diff --git a/frontend/mock/server.js b/frontend/mock/server.js index 5cbc171..4f1121c 100644 --- a/frontend/mock/server.js +++ b/frontend/mock/server.js @@ -1,37 +1,39 @@ -// Mock API server for Neato web UI development -// Mimics all firmware REST endpoints with realistic stateful responses -// Runs as a Vite plugin — hooks into Vite's dev server middleware -// To test different scenarios, edit the `state` object directly and reload +// Mock API server for Neato web UI development. +// Runs as a Vite plugin and delegates route behavior to shared-api.js so the +// Cloudflare demo Worker and local dev server stay aligned. -const { createHash } = require("node:crypto"); -const { execSync } = require("node:child_process"); -const { readFileSync, readdirSync } = require("node:fs"); -const { join } = require("node:path"); +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createMockApi } from "./shared-api.js"; +import { createScenarioState, scenarioFromRequest } from "./shared-state.js"; +import { DEFAULT_MOCK_VERSION, mockVersionFromHash } from "./shared-version.js"; -// --- Helpers --- +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; const getVersion = () => { try { const hash = execSync("git rev-parse --short=7 HEAD", { encoding: "utf8" }).trim(); - return `0.0-${hash}`; + return mockVersionFromHash(hash); } catch { - return "0.0"; + return DEFAULT_MOCK_VERSION; } }; -const jsonResponse = (res, data, status = 200) => { - const body = JSON.stringify(data); - res.writeHead(status, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }); - res.end(body); +const sendResponse = (res, response) => { + const headers = { ...response.headers }; + if (response.body !== undefined && !headers["Content-Length"]) { + headers["Content-Length"] = Buffer.byteLength(response.body); + } + res.writeHead(response.status, headers); + res.end(response.body ?? ""); }; -const sendOk = (res) => jsonResponse(res, { ok: true }); -const sendError = (res, msg, status = 500) => jsonResponse(res, { error: msg }, status); - -// Request logging — captures original writeHead to log via Vite's logger +// Request logging - captures original writeHead to log via Vite's logger. let viteLogger = null; const logRequest = (req, res) => { @@ -48,320 +50,97 @@ const logRequest = (req, res) => { }; }; -const readBody = (req) => +const readBodyBytes = (req) => new Promise((resolve) => { - let body = ""; - req.on("data", (chunk) => (body += chunk)); - req.on("end", () => resolve(body)); + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); }); -const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const _randf = (min, max, decimals = 2) => parseFloat((Math.random() * (max - min) + min).toFixed(decimals)); - -// --- Scenario selector --- -// Change this value to switch between test states. Save and Vite hot-reloads. -// Combine multiple scenarios with "|": -// "ok" — Robot idle, online, battery 85% -// "err|fa" — Robot error (brush stuck) + action faults -// "low|fl|fs" — Low battery + log faults + settings fault -// "man|llq" — Manual clean + low LIDAR quality -// -// Robot state: -// ok — Idle, battery 85% off — Device unreachable -// ident — Identifying robot (boot) unsup — Unsupported robot model -// upd — Firmware v0.9 (triggers update banner) -// cls — House cleaning spt — Spot cleaning -// dock — Docking (return to base) rchg — Mid-clean recharge (on dock, charging) -// chg — Charging, 62% ch2 — Charging, 25% -// ful — Full, on dock mid — Battery 45% -// low — Battery 12% ded — Battery 0% -// err — Brush stuck error alrt — Alert only (brush change) -// -// Manual clean (combine with each other or fault scenarios): -// man — Manual mode active (no safety issues) -// mlf — Manual + robot lifted -// mbf — Manual + front-left bumper contact -// mbs — Manual + side-right bumper contact -// msf — Manual + forward stall (reverse to clear) -// msr — Manual + rear stall (move forward to clear) -// -// LIDAR quality (combine with any state): -// llq — Low scan quality (<90 valid points) -// lsl — Slow LDS rotation (2.8 Hz) -// lno — LIDAR unavailable (GET /api/lidar returns error) -// -// Fault injection (combine with any state above): -// fa — Action faults (clean house/spot/stop return errors) -// fs — Settings fault (NVS write error) -// flr — Log read fault (list + content fail) -// fld — Log delete fault (delete single + delete all fail) -// fl — All log faults (read + delete) -// fps — Polling fault: GET /api/state -// fpc — Polling fault: GET /api/charger -// fpe — Polling fault: GET /api/error -// fp — All polling faults (state + charger + error) -// fhc — History corruption (inject corrupted pose lines in session data) -// fal — All faults combined -const SCENARIO = "ok"; - -// --- Robot state --- - -const SCENARIOS = { - ok: {}, - off: { offline: true }, - cls: { cleaning: true }, - spt: { spotCleaning: true }, - chg: { fuelPercent: 62, chargingActive: true, extPwrPresent: true }, - ch2: { fuelPercent: 25, chargingActive: true, extPwrPresent: true }, - ful: { fuelPercent: 100, chargingActive: false, extPwrPresent: true }, - mid: { fuelPercent: 45 }, - low: { fuelPercent: 12 }, - ded: { fuelPercent: 0 }, - err: { - hasError: true, - kind: "error", - errorCode: 265, - errorMessage: - "Error\r\n265 - (UI_ERROR_BRUSH_STUCK)\r\nAlert\r\n205 - (UI_ALERT_DUST_BIN_FULL)\r\nUSB state \r\n NOT connected", - displayMessage: "Main brush is stuck", - }, - alrt: { - hasError: true, - kind: "warning", - errorCode: 229, - errorMessage: "Error\r\n200 - (UI_ALERT_INVALID)\r\nAlert\r\n229 - (UI_ALERT_BRUSH_CHANGE)", - displayMessage: "Time to replace the brush", - }, - dock: { docking: true, cleaning: false }, - rchg: { - midCleanRecharge: true, - fuelPercent: 15, - chargingActive: true, - extPwrPresent: true, - }, - man: { manualClean: true }, - mlf: { manualClean: true, manualLifted: true }, - mbf: { manualClean: true, manualBumperFrontLeft: true }, - mbs: { manualClean: true, manualBumperSideRight: true }, - msf: { manualClean: true, manualStallFront: true }, - msr: { manualClean: true, manualStallRear: true }, - ident: { identifying: true }, - unsup: { unsupported: true }, - upd: { firmwareVersion: "0.9" }, - llq: { lidarLowQuality: true }, - lsl: { lidarSlowRotation: true }, - lno: { lidarUnavailable: true }, - fa: { faults: { actions: true } }, - fs: { faults: { settings: true } }, - flr: { faults: { logsRead: true } }, - fld: { faults: { logsDelete: true } }, - fl: { faults: { logsRead: true, logsDelete: true } }, - fps: { faults: { pollState: true } }, - fpc: { faults: { pollCharger: true } }, - fpe: { faults: { pollError: true } }, - fp: { faults: { pollState: true, pollCharger: true, pollError: true } }, - fhc: { faults: { historyCorrupt: true } }, - fal: { - faults: { - actions: true, - settings: true, - logsRead: true, - logsDelete: true, - pollState: true, - pollCharger: true, - pollError: true, - historyCorrupt: true, - }, - }, -}; - -// Merge scenarios split by "|" -const merged = {}; -const mergedFaults = {}; -for (const key of SCENARIO.split("|")) { - const s = SCENARIOS[key] || {}; - const { faults: sf, ...rest } = s; - Object.assign(merged, rest); - if (sf) Object.assign(mergedFaults, sf); -} -merged.faults = mergedFaults; - -// Fault flags — toggled by scenarios, checked by route handlers -const faults = { - actions: false, - settings: false, - logsRead: false, - logsDelete: false, - pollState: false, - pollCharger: false, - pollError: false, - historyCorrupt: false, - ...(merged.faults || {}), -}; - -const state = { - offline: false, - fuelPercent: 85, - chargingActive: false, - extPwrPresent: false, - cleaning: false, - spotCleaning: false, - docking: false, - paused: false, - uiState: "UIMGR_STATE_IDLE", - robotState: "ST_C_Idle", - hasError: false, - kind: "", - errorCode: 200, - errorMessage: "", - displayMessage: "", - testMode: false, - manualClean: false, - // Manual clean motor + safety state - manualBrush: false, - manualVacuum: false, - manualSideBrush: false, - manualLifted: false, - manualBumperFrontLeft: false, - manualBumperFrontRight: false, - manualBumperSideLeft: false, - manualBumperSideRight: false, - manualStallFront: false, - manualStallRear: false, - // Mid-clean recharge (docking to charge, then resume) - midCleanRecharge: false, - // Robot model - identifying: false, - unsupported: false, - // Firmware version override (null = auto from git hash) - firmwareVersion: null, - // LIDAR quality overrides - lidarLowQuality: false, - lidarSlowRotation: false, - tz: "UTC0", - logLevel: 0, - syslogEnabled: false, - syslogIp: "", - wifiTxPower: 60, // 15 dBm in 0.25 dBm units - uartTxPin: 3, - uartRxPin: 4, - maxGpioPin: 21, - hostname: "neato", - navMode: "Normal", - stallThreshold: 60, - brushRpm: 1200, - vacuumSpeed: 80, - sideBrushPower: 1500, - ntfyTopic: "", - ntfyServer: "", - ntfyToken: "", - ntfyEnabled: true, - ntfyOnStart: true, - ntfyOnDone: true, - ntfyOnError: true, - ntfyOnAlert: true, - ntfyOnDocking: true, - // Robot user settings (from GetUserSettings) - buttonClick: true, - melodies: true, - warnings: true, - ecoMode: false, - intenseClean: false, - binFullDetect: true, - wallEnable: true, - wifi: true, - stealthLed: false, - filterChange: 2592000, - brushChange: 2592000, - dirtBin: 30, - // Schedule (Mon=0..Sun=6), two slots per day - scheduleEnabled: true, - // Slot 0 (primary) - sched0Hour: 9, - sched0Min: 0, - sched0On: true, // Mon - sched1Hour: 9, - sched1Min: 0, - sched1On: true, // Tue - sched2Hour: 9, - sched2Min: 0, - sched2On: true, // Wed - sched3Hour: 9, - sched3Min: 0, - sched3On: true, // Thu - sched4Hour: 9, - sched4Min: 0, - sched4On: true, // Fri - sched5Hour: 0, - sched5Min: 0, - sched5On: false, // Sat - sched6Hour: 0, - sched6Min: 0, - sched6On: false, // Sun - // Slot 1 (secondary) - sched0Slot1Hour: 15, - sched0Slot1Min: 0, - sched0Slot1On: true, // Mon afternoon - sched1Slot1Hour: 15, - sched1Slot1Min: 0, - sched1Slot1On: true, // Tue afternoon - sched2Slot1Hour: 15, - sched2Slot1Min: 0, - sched2Slot1On: true, // Wed afternoon - sched3Slot1Hour: 15, - sched3Slot1Min: 0, - sched3Slot1On: true, // Thu afternoon - sched4Slot1Hour: 15, - sched4Slot1Min: 0, - sched4Slot1On: true, // Fri afternoon - sched5Slot1Hour: 0, - sched5Slot1Min: 0, - sched5Slot1On: false, // Sat - sched6Slot1Hour: 0, - sched6Slot1Min: 0, - sched6Slot1On: false, // Sun - ...merged, +const createRequestAdapter = (req, parsed) => { + let cachedBody = null; + const bytes = async () => { + cachedBody ??= await readBodyBytes(req); + return cachedBody; + }; + return { + method: req.method, + path: parsed.pathname, + query: Object.fromEntries(parsed.searchParams), + bytes, + text: async () => (await bytes()).toString("utf8"), + }; }; -// Boot time for uptime calculation (mutable — reset by simulated reboot) +// Select scenarios with ?scenario=err|fa. The page stores it in a cookie so +// subsequent SPA API calls keep using the selected mock state. +let initializedScenario = null; let bootTime = Date.now(); -// --- Derived helpers --- - -const vBattFromFuel = (fuel) => parseFloat((12.0 + (fuel / 100) * 4.6).toFixed(2)); - -// --- LIDAR captured scans (real data from Neato D7) --- - const lidarScans = readdirSync(__dirname) .filter((f) => f.startsWith("lidar-scan") && f.endsWith(".json")) .sort() .map((f) => JSON.parse(readFileSync(join(__dirname, f), "utf8"))); let lidarScanIndex = 0; -// --- Map data — in-memory session store --- -// Load mapdata-*.jsonl files into memory at startup. The active recording -// session (no summary line) grows via random-walk timer — no file I/O. +const getLidarScan = () => { + const scan = lidarScans[lidarScanIndex]; + lidarScanIndex = (lidarScanIndex + 1) % lidarScans.length; + return scan; +}; -const historySessions = new Map(); // name -> string[] (JSONL lines) +const loadHistorySessions = () => { + const sessions = new Map(); -for (const f of readdirSync(__dirname) - .filter((n) => n.startsWith("mapdata-") && n.endsWith(".jsonl")) - .sort()) { - const lines = readFileSync(join(__dirname, f), "utf8") - .trim() - .split("\n") - .filter((l) => l.length > 0); - historySessions.set(f, lines); -} + for (const f of readdirSync(__dirname) + .filter((n) => n.startsWith("mapdata-") && n.endsWith(".jsonl")) + .sort()) { + const lines = readFileSync(join(__dirname, f), "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0); + sessions.set(f, lines); + } + + return sessions; +}; -// Find the active recording session (no summary line) and start random-walk timer -const recordingFile = [...historySessions.entries()].find( - ([, lines]) => !lines.some((l) => l.includes('"type":"summary"')), +const context = { + state: {}, + faults: {}, + historySessions: loadHistorySessions(), + rand, + getVersion, + getLidarScan, + getBootTime: () => bootTime, + reboot: () => { + setTimeout(() => { + bootTime = Date.now(); + }, 2000); + }, + shutdown: () => setTimeout(() => process.exit(0), 500), + md5Hex: (bytes) => createHash("md5").update(Buffer.from(bytes)).digest("hex"), +}; + +const initScenario = (scenario) => { + if (scenario === initializedScenario) return; + const scenarioState = createScenarioState(scenario); + context.state = scenarioState.state; + context.faults = scenarioState.faults; + context.historySessions = loadHistorySessions(); + bootTime = Date.now(); + initializedScenario = scenario; +}; + +initScenario("ok"); + +const recordingFile = [...context.historySessions.entries()].find( + ([, lines]) => !lines.some((line) => line.includes('"type":"summary"')), ); if (recordingFile) { const [recordingName, recordingLines] = recordingFile; - const lastPose = [...recordingLines].reverse().find((l) => l.includes('"x":')); + const lastPose = [...recordingLines].reverse().find((line) => line.includes('"x":')); const pos = lastPose ? JSON.parse(lastPose) : { x: 0, y: 0, t: 0, ts: 7244 }; let simX = pos.x; let simY = pos.y; @@ -377,815 +156,42 @@ if (recordingFile) { simX += Math.cos(rad) * step; simY -= Math.sin(rad) * step; simTs += 2.0 + Math.random() * 0.3; - const lines = historySessions.get(recordingName); - // Rolling window: drop oldest coordinate (keep session header at [0]) + const lines = context.historySessions.get(recordingName); if (lines.length > 1) lines.splice(1, 1); lines.push(`{"x":${simX.toFixed(3)},"y":${simY.toFixed(3)},"t":${simT.toFixed(1)},"ts":${simTs.toFixed(1)}}`); }, 2000); } -const getLidarScan = () => { - const scan = lidarScans[lidarScanIndex]; - lidarScanIndex = (lidarScanIndex + 1) % lidarScans.length; - return scan; -}; - -// --- Mock log files --- - -const mockLogs = [ - { name: "current.jsonl", size: 8192, compressed: false }, - { name: "1700000000.jsonl.hs", size: 4096, compressed: true }, - { name: "boot_2501.jsonl.hs", size: 1851, compressed: true }, -]; - -const mockLogContent = [ - '{"t":1700000100,"typ":"boot","d":{"reason":"power_on","heap":195000}}', - '{"t":1700000101,"typ":"wifi","d":{"event":"connected","rssi":-52}}', - '{"t":1700000102,"typ":"ntp","d":{"event":"sync_ok","epoch":1700000102}}', - '{"t":1700000200,"typ":"command","d":{"cmd":"GetCharger","status":"ok","ms":85,"q":0,"bytes":312,"resp":"GetCharger\\r\\nLabel,Value\\r\\nFuelPercent,85\\r\\nBatteryOverTemp,0\\r\\nChargingActive,0\\r\\nChargingEnabled,1\\r\\n"}}', - '{"t":1700000202,"typ":"command","d":{"cmd":"GetState","status":"ok","ms":42,"q":0,"bytes":95,"resp":"GetState\\r\\nCurrent UI State is: UIMGR_STATE_IDLE\\nCurrent Robot State is: ST_C_Idle\\n"}}', - '{"t":1700000203,"typ":"command","d":{"cmd":"GetState","status":"ok","ms":0,"q":0,"bytes":0,"age":1}}', - '{"t":1700000210,"typ":"request","d":{"method":"GET","path":"/api/charger","status":200,"ms":92}}', - '{"t":1700000215,"typ":"command","d":{"cmd":"GetErr","status":"ok","ms":38,"q":0,"bytes":64,"resp":"GetErr\\r\\nError\\r\\n200 - (UI_ALERT_INVALID)\\r\\nAlert\\r\\n200 - (UI_ALERT_INVALID)\\r\\n"}}', - '{"t":1700000216,"typ":"command","d":{"cmd":"GetErr","status":"ok","ms":0,"q":0,"bytes":0,"age":1}}', - '{"t":1700000220,"typ":"request","d":{"method":"GET","path":"/api/state","status":200,"ms":48}}', - '{"t":1700000300,"typ":"command","d":{"cmd":"GetAnalogSensors","status":"ok","ms":120,"q":1,"bytes":480}}', - '{"t":1700000305,"typ":"request","d":{"method":"POST","path":"/api/clean?action=house","status":200,"ms":210}}', - '{"t":1700000306,"typ":"command","d":{"cmd":"Clean House","status":"ok","ms":95,"q":0,"bytes":28}}', - '{"t":1700000400,"typ":"event","d":{"msg":"cleaning_started","mode":"house"}}', - '{"t":1700000500,"typ":"command","d":{"cmd":"GetMotors","status":"ok","ms":110,"q":2,"bytes":520}}', - '{"t":1700000600,"typ":"command","d":{"cmd":"GetLDSScan","status":"ok","ms":450,"q":0,"bytes":8200}}', - '{"t":1700000601,"typ":"command","d":{"cmd":"GetCharger","status":"ok","ms":0,"q":0,"bytes":0,"age":401}}', - - '{"t":1700000615,"typ":"request","d":{"method":"GET","path":"/api/lidar","status":200,"ms":460}}', - '{"t":1700000620,"typ":"command","d":{"cmd":"GetLDSScan","status":"ok","ms":440,"q":1,"bytes":8140}}', - '{"t":1700000621,"typ":"command","d":{"cmd":"GetLDSScan","status":"ok","ms":0,"q":0,"bytes":0,"age":1}}', - - '{"t":1700000700,"typ":"event","d":{"msg":"cleaning_completed","duration":600}}', -].join("\n"); - -// --- Derive UI/robot state from current state --- - -const deriveStates = () => { - if (state.manualClean) { - state.uiState = "UIMGR_STATE_MANUALCLEANING"; - state.robotState = "ST_C_ManualCleaning"; - } else if (state.testMode) { - state.uiState = "UIMGR_STATE_TESTMODE"; - state.robotState = "ST_C_TestMode"; - } else if (state.midCleanRecharge) { - state.uiState = "UIMGR_STATE_CLEANINGSUSPENDED"; - state.robotState = "ST_M1_Charging_Cleaning"; - } else if (state.docking) { - state.uiState = "UIMGR_STATE_DOCKINGRUNNING"; - state.robotState = "ST_C_Docking"; - } else if (state.cleaning && !state.paused) { - state.uiState = "UIMGR_STATE_HOUSECLEANINGRUNNING"; - state.robotState = "ST_C_HouseCleaning"; - } else if (state.cleaning && state.paused) { - state.uiState = "UIMGR_STATE_HOUSECLEANINGPAUSED"; - state.robotState = "ST_C_Standby"; - } else if (state.spotCleaning && !state.paused) { - state.uiState = "UIMGR_STATE_SPOTCLEANINGRUNNING"; - state.robotState = "ST_C_SpotCleaning"; - } else if (state.spotCleaning && state.paused) { - state.uiState = "UIMGR_STATE_SPOTCLEANINGPAUSED"; - state.robotState = "ST_C_Standby"; - } else { - state.uiState = "UIMGR_STATE_IDLE"; - state.robotState = "ST_C_Idle"; - } -}; - -// --- Route handlers --- - -const routes = { - // Sensor routes - "GET /api/version": (_req, res) => { - jsonResponse(res, { - modelName: "BotVacD7", - serialNumber: "OPS01234AA,0000001,D", - softwareVersion: "4.5.3-142", - ldsVersion: "V2.6.15295", - ldsSerial: "KSH-V5F4", - mainBoardVersion: "15.0", - }); - }, - - "GET /api/charger": (_req, res) => { - if (faults.pollCharger) return sendError(res, "UART timeout reading charger", 500); - const fuel = Math.round(state.fuelPercent); - jsonResponse(res, { - fuelPercent: fuel, - batteryOverTemp: false, - chargingActive: state.chargingActive, - chargingEnabled: true, - confidOnFuel: fuel > 20, - onReservedFuel: fuel < 10, - emptyFuel: fuel === 0, - batteryFailure: false, - extPwrPresent: state.extPwrPresent, - vBattV: vBattFromFuel(fuel), - vExtV: state.extPwrPresent ? 22.3 : 0.0, - chargerMAH: state.chargingActive ? 1200 : 0, - dischargeMAH: Math.round(((100 - fuel) / 100) * 2800), - }); - }, - - "GET /api/motors": (_req, res) => { - const cleaning = state.cleaning || state.spotCleaning; - jsonResponse(res, { - brushRPM: cleaning ? rand(1100, 1300) : 0, - brushMA: cleaning ? rand(200, 400) : 0, - vacuumRPM: cleaning ? rand(2200, 2600) : 0, - vacuumMA: cleaning ? rand(400, 700) : 0, - leftWheelRPM: cleaning ? rand(60, 120) : 0, - leftWheelLoad: cleaning ? rand(10, 40) : 0, - leftWheelPositionMM: state.leftWheelPos, - leftWheelSpeed: cleaning ? rand(150, 300) : 0, - rightWheelRPM: cleaning ? rand(60, 120) : 0, - rightWheelLoad: cleaning ? rand(10, 40) : 0, - rightWheelPositionMM: state.rightWheelPos, - rightWheelSpeed: cleaning ? rand(150, 300) : 0, - sideBrushMA: cleaning ? rand(50, 200) : 0, - laserRPM: cleaning ? rand(290, 310) : 0, - }); - }, - - "GET /api/state": (_req, res) => { - if (faults.pollState) return sendError(res, "UART timeout reading state", 500); - deriveStates(); - jsonResponse(res, { - uiState: state.uiState, - robotState: state.robotState, - }); - }, - - "GET /api/error": (_req, res) => { - if (faults.pollError) return sendError(res, "UART timeout reading error", 500); - jsonResponse(res, { - hasError: state.hasError, - kind: state.kind, - errorCode: state.errorCode, - errorMessage: state.errorMessage, - displayMessage: state.displayMessage, - }); - }, - - "GET /api/lidar": (_req, res) => { - if (state.lidarUnavailable) return sendError(res, "UART timeout reading LDS scan", 500); - const scan = getLidarScan(); - // Degrade scan for quality scenarios - if (state.lidarLowQuality || state.lidarSlowRotation) { - const degraded = { ...scan }; - if (state.lidarSlowRotation) degraded.rotationSpeed = 2.8; - if (state.lidarLowQuality) { - // Zero out most points to simulate poor readings - degraded.points = scan.points.map((p, i) => - i % 5 === 0 ? p : { ...p, dist: 0, intensity: 0, error: 8035 }, - ); - degraded.validPoints = degraded.points.filter((p) => p.dist > 0).length; - } - return jsonResponse(res, degraded); - } - jsonResponse(res, scan); - }, - - // Action routes — parameterized via query string - "POST /api/clean": (_req, res, query) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - const action = query.action || "house"; - if (action === "dock") { - if (state.cleaning || state.spotCleaning) { - state.docking = true; // Simulate return-to-base - state.cleaning = false; - state.spotCleaning = false; - state.paused = false; - } - } else if (action === "pause") { - if ((state.cleaning || state.spotCleaning) && !state.paused) { - state.paused = true; // Running -> Paused - } - } else if (action === "stop") { - state.cleaning = false; - state.spotCleaning = false; - state.docking = false; - state.paused = false; - } else if (action === "spot") { - state.spotCleaning = true; - state.cleaning = false; - state.docking = false; - state.paused = false; - } else { - state.cleaning = true; - state.spotCleaning = false; - state.docking = false; - state.paused = false; - } - deriveStates(); - sendOk(res); - }, - - "GET /api/manual/status": (_req, res) => { - jsonResponse(res, { - active: state.manualClean, - brush: state.manualBrush, - vacuum: state.manualVacuum, - sideBrush: state.manualSideBrush, - lifted: state.manualLifted, - bumperFrontLeft: state.manualBumperFrontLeft, - bumperFrontRight: state.manualBumperFrontRight, - bumperSideLeft: state.manualBumperSideLeft, - bumperSideRight: state.manualBumperSideRight, - stallFront: state.manualStallFront, - stallRear: state.manualStallRear, - }); - }, - - "POST /api/manual/move": (_req, res) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - if (!state.manualClean) return sendError(res, "Not in manual mode", 400); - sendOk(res); - }, - - "POST /api/manual/motors": (_req, res, query) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - if (!state.manualClean) return sendError(res, "Not in manual mode", 400); - state.manualBrush = query.brush === "1"; - state.manualVacuum = query.vacuum === "1"; - state.manualSideBrush = query.sideBrush === "1"; - setTimeout(() => sendOk(res), 600); - }, - - "POST /api/power": (_req, res, query) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - const action = query.action; - if (action === "restart") { - // Simulate robot power cycle — robot recovers in ~1s, ESP32 stays online - sendOk(res); - } else if (action === "shutdown") { - // Simulate real ESP32 power loss — respond then kill the dev server - sendOk(res); - setTimeout(() => process.exit(0), 500); - } else { - sendError(res, "unknown action", 400); - } - }, - - "POST /api/clear-errors": (_req, res) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - sendOk(res); - }, - - "POST /api/sound": (_req, res) => { - // Accept and ignore — just acknowledge - sendOk(res); - }, - - "POST /api/testmode": (_req, res, query) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - state.testMode = query.enable === "1"; - deriveStates(); - sendOk(res); - }, - - "POST /api/manual": (_req, res, query) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - const enable = query.enable === "1"; - state.manualClean = enable; - if (enable) { - state.cleaning = false; - state.spotCleaning = false; - state.paused = false; - } - // Reset motor and safety state on enable/disable - state.manualBrush = false; - state.manualVacuum = false; - state.manualSideBrush = false; - if (!enable) { - state.manualLifted = false; - state.manualBumperFrontLeft = false; - state.manualBumperFrontRight = false; - state.manualBumperSideLeft = false; - state.manualBumperSideRight = false; - state.manualStallFront = false; - state.manualStallRear = false; - } - deriveStates(); - sendOk(res); - }, - - "POST /api/lidar/rotate": (_req, res) => { - if (faults.actions) return sendError(res, "UART timeout: robot not responding", 500); - // LDS rotation is fire-and-forget — no state change visible in polls - sendOk(res); - }, - - // Log routes - "GET /api/logs": (_req, res) => { - if (faults.logsRead) return sendError(res, "SPIFFS read failed", 500); - jsonResponse(res, mockLogs); - }, - - "DELETE /api/logs": (_req, res) => { - if (faults.logsDelete) return sendError(res, "SPIFFS busy, try again later", 503); - sendOk(res); - }, - - // System routes - "GET /api/system": (_req, res) => { - const now = new Date(); - const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const pad = (n) => n.toString().padStart(2, "0"); - const localTime = `${days[now.getDay()]} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; - jsonResponse(res, { - heap: rand(160000, 200000), - heapTotal: 327680, - uptime: Date.now() - bootTime, - rssi: rand(-65, -40), - fsUsed: rand(10000, 50000), - fsTotal: 262144, - ntpSynced: true, - time: Math.floor(Date.now() / 1000), - timeSource: "ntp", - tz: state.tz, - localTime, - isDst: now.getTimezoneOffset() !== new Date(now.getFullYear(), 0, 1).getTimezoneOffset(), - }); - }, - - "POST /api/system/restart": (_req, res) => { - sendOk(res); - setTimeout(() => { - bootTime = Date.now(); - }, 2000); - }, - - "POST /api/system/format-fs": (_req, res) => { - sendOk(res); - setTimeout(() => { - bootTime = Date.now(); - }, 2000); - }, - - "POST /api/system/reset": (_req, res) => { - sendOk(res); - setTimeout(() => { - bootTime = Date.now(); - }, 2000); - }, - - "GET /api/settings": (_req, res) => { - const s = {}; - const keys = [ - "tz", - "logLevel", - "syslogEnabled", - "syslogIp", - "wifiTxPower", - "uartTxPin", - "uartRxPin", - "maxGpioPin", - "hostname", - "navMode", - "stallThreshold", - "brushRpm", - "vacuumSpeed", - "sideBrushPower", - "ntfyTopic", - "ntfyServer", - "ntfyToken", - "ntfyEnabled", - "ntfyOnStart", - "ntfyOnDone", - "ntfyOnError", - "ntfyOnAlert", - "ntfyOnDocking", - "scheduleEnabled", - ]; - for (const k of keys) s[k] = state[k]; - for (let d = 0; d < 7; d++) { - s[`sched${d}Hour`] = state[`sched${d}Hour`]; - s[`sched${d}Min`] = state[`sched${d}Min`]; - s[`sched${d}On`] = state[`sched${d}On`]; - s[`sched${d}Slot1Hour`] = state[`sched${d}Slot1Hour`]; - s[`sched${d}Slot1Min`] = state[`sched${d}Slot1Min`]; - s[`sched${d}Slot1On`] = state[`sched${d}Slot1On`]; - } - jsonResponse(res, s); - }, - - "POST /api/notifications/test": (_req, res, query) => { - if (!query.topic) return sendError(res, "missing topic", 400); - sendOk(res); - }, - - "GET /repos/renjfk/OpenNeato/releases/latest": (_req, res) => { - jsonResponse(res, { tag_name: "v1.0" }); - }, - - "GET /api/user-settings": (_req, res) => { - jsonResponse(res, { - buttonClick: state.buttonClick, - melodies: state.melodies, - warnings: state.warnings, - ecoMode: state.ecoMode, - intenseClean: state.intenseClean, - binFullDetect: state.binFullDetect, - wallEnable: state.wallEnable, - wifi: state.wifi, - stealthLed: state.stealthLed, - filterChange: state.filterChange, - brushChange: state.brushChange, - dirtBin: state.dirtBin, - }); - }, - - "POST /api/user-settings": (_req, res, query) => { - const keyMap = { - ButtonClick: "buttonClick", - Melodies: "melodies", - Warnings: "warnings", - EcoMode: "ecoMode", - IntenseClean: "intenseClean", - BinFullDetect: "binFullDetect", - WallEnable: "wallEnable", - WiFi: "wifi", - StealthLED: "stealthLed", - FilterChange: "filterChange", - BrushChange: "brushChange", - DirtBin: "dirtBin", - }; - const serialKey = query.key; - const value = query.value; - if (!serialKey || !value) return sendError(res, "missing key or value", 400); - const stateKey = keyMap[serialKey]; - if (!stateKey) return sendError(res, "unknown key", 400); - if (["filterChange", "brushChange", "dirtBin"].includes(stateKey)) { - state[stateKey] = parseInt(value, 10); - } else { - state[stateKey] = value.toUpperCase() === "ON"; - } - sendOk(res); - }, - - "GET /api/firmware/version": (_req, res) => { - jsonResponse(res, { - version: state.firmwareVersion ?? getVersion(), - chip: "ESP32-C3", - hostname: "neato-kitchen", - supported: !state.unsupported && !state.identifying, - identifying: state.identifying, - }); - }, -}; - -// --- Core request handler --- +const api = createMockApi(context); const handleRequest = async (req, res) => { - // Simulate device unreachable — drop connection - if (state.offline) { - req.destroy(); - return; - } - const parsed = new URL(req.url, "http://localhost"); - const path = parsed.pathname; - const query = Object.fromEntries(parsed.searchParams); - - // Match history routes: GET /api/history, GET/DELETE /api/history/{filename} - // Inject corrupted pose lines to test frontend repair logic (simulates - // heatshrink decompression artifacts: '.' -> ':', '.' -> '"', digit -> letter) - const injectCorruptedPoses = (lines) => { - const corruptions = [ - '{"x":-0.798,"y":3.459,"t":100:5,"ts":8203.4}', - '{"x":-0.785,"y":4.192,"t":100:3,"ts":8208.1}', - '{"x":-1.286,"y":4.007,"t":177:5,"ts":8215.6}', - '{"x":-1.8"3,"y":2.254,"t":181.0,"ts":8288.6}', - '{"x":-1.946,"y":2.0"3,"t":181.9,"ts":8312.8}', - '{"x":-4.793,"y":-1.6t2,"t":356.5,"ts":9182.5}', - '{"x":-1.740,"y":3.510,"t":1.6,"ts":8242:0}', - ]; - const result = [lines[0]]; - for (let i = 1; i < lines.length; i++) { - result.push(lines[i]); - // Sprinkle corrupted lines at ~5% rate among pose lines - if (!lines[i].includes('"type"') && i % 20 === 0) { - result.push(corruptions[i % corruptions.length]); - } - } - return result; - }; - - if (path === "/api/history" && req.method === "GET") { - // List sessions from in-memory store with embedded session/summary metadata - const list = [...historySessions.entries()].map(([name, lines]) => { - let session = null; - let summary = null; - if (lines.length > 0) { - try { - const first = JSON.parse(lines[0]); - if (first.type === "session") session = first; - } catch {} - } - if (lines.length > 1) { - try { - const last = JSON.parse(lines[lines.length - 1]); - if (last.type === "summary") summary = last; - } catch {} - } - const raw = `${lines.join("\n")}\n`; - return { - name, - size: Buffer.byteLength(raw), - compressed: name.endsWith(".hs"), - recording: summary === null, - session, - summary, - }; - }); - return jsonResponse(res, list); - } - - if (path === "/api/history" && req.method === "DELETE") { - historySessions.clear(); - return sendOk(res); - } - - // POST /api/history/import — accept multipart upload, parse JSONL, store in memory - if (path === "/api/history/import" && req.method === "POST") { - const chunks = []; - await new Promise((resolve) => { - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", resolve); - }); - const body = Buffer.concat(chunks); - const bodyStr = body.toString("binary"); - // Extract filename from Content-Disposition header in multipart body - const nameMatch = bodyStr.match(/filename="([^"]+)"/); - if (!nameMatch || !nameMatch[1].endsWith(".jsonl")) { - return sendError(res, "Invalid file: expected a .jsonl session file", 400); - } - const filename = nameMatch[1]; - // Extract file content between multipart headers and closing boundary - const headerEnd = bodyStr.indexOf("\r\n\r\n"); - const boundaryEnd = bodyStr.lastIndexOf("\r\n--"); - if (headerEnd < 0 || boundaryEnd < 0) { - return sendError(res, "Malformed multipart body", 400); - } - const content = body.subarray(headerEnd + 4, boundaryEnd).toString("utf8"); - const lines = content - .trim() - .split("\n") - .filter((l) => l.length > 0); - if (lines.length === 0) { - return sendError(res, "Empty session file", 400); - } - // Validate first line is a session header with matching timestamp - try { - const header = JSON.parse(lines[0]); - if (header.type !== "session") { - return sendError(res, "First line must be a session header", 400); - } - // Filename is .jsonl — extract epoch and compare to header time - const fileEpoch = filename.replace(".jsonl", ""); - if (header.time !== undefined && String(header.time) !== fileEpoch) { - return sendError(res, "Session timestamp does not match filename", 400); - } - } catch { - return sendError(res, "Invalid JSON in session header", 400); - } - // Check for duplicate — firmware stores as .hs, but also check raw name - const storedName = `${filename}.hs`; - if (historySessions.has(storedName) || historySessions.has(filename)) { - return sendError(res, "Session already exists", 409); - } - historySessions.set(storedName, lines); - // Simulate compression delay - await new Promise((r) => setTimeout(r, rand(200, 500))); - return sendOk(res); - } - - const historyMatch = path.match(/^\/api\/history\/(.+)$/); - if (historyMatch) { - const filename = historyMatch[1]; - if (req.method === "GET") { - const lines = historySessions.get(filename); - if (!lines) return sendError(res, "session not found", 404); - const served = faults.historyCorrupt ? injectCorruptedPoses(lines) : lines; - const raw = `${served.join("\n")}\n`; - res.writeHead(200, { - "Content-Type": "application/x-ndjson", - "Content-Length": Buffer.byteLength(raw), - }); - return res.end(raw); - } - if (req.method === "DELETE") { - historySessions.delete(filename); - return sendOk(res); - } - return sendError(res, "method not allowed", 405); - } - - // Match log file routes: GET/DELETE /api/logs/{filename} - const logFileMatch = path.match(/^\/api\/logs\/(.+)$/); - if (logFileMatch) { - const filename = logFileMatch[1]; - if (req.method === "GET") { - if (faults.logsRead) return sendError(res, "SPIFFS read failed", 500); - res.writeHead(200, { - "Content-Type": "application/x-ndjson", - "Content-Disposition": `attachment; filename="${filename.replace(/\.hs$/, "")}"`, - }); - return res.end(mockLogContent); - } - if (req.method === "DELETE") { - if (faults.logsDelete) return sendError(res, "SPIFFS busy, try again later", 503); - return sendOk(res); - } - return sendError(res, "method not allowed", 405); - } - - // POST /api/firmware/update?hash= — validate chip ID + MD5, simulate flash write, reboot - if (req.method === "POST" && path === "/api/firmware/update") { - const expectedMd5 = query.hash || ""; - if (!expectedMd5) return sendError(res, "MD5 hash required", 400); - const MOCK_CHIP_ID = 5; // ESP32-C3 - const chunks = []; - await new Promise((resolve) => { - req.on("data", (chunk) => chunks.push(chunk)); - req.on("end", resolve); - }); - // Extract file bytes from multipart body (skip boundary/headers, find file content) - const body = Buffer.concat(chunks); - const bodyStr = body.toString("binary"); - const headerEnd = bodyStr.indexOf("\r\n\r\n"); - // Find end of file content (before the closing boundary) - const boundaryEnd = bodyStr.lastIndexOf("\r\n--"); - const fileStart = headerEnd !== -1 ? headerEnd + 4 : -1; - const fileEnd = boundaryEnd > fileStart ? boundaryEnd : body.length; - if (fileStart !== -1) { - if (body.length >= fileStart + 16) { - const chipId = body[fileStart + 12]; - if (chipId !== MOCK_CHIP_ID) { - return sendError(res, "Firmware chip mismatch: file targets a different ESP32 variant", 400); - } - } - // MD5 verification — mirrors ESP32 Update.setMD5 / Update.end(true) behavior - const fileBytes = body.subarray(fileStart, fileEnd); - const actualMd5 = createHash("md5").update(fileBytes).digest("hex"); - if (actualMd5 !== expectedMd5.toLowerCase()) { - return sendError(res, "MD5 mismatch: firmware integrity check failed", 400); - } - } - // Simulate flash write: 3-5s delay - await new Promise((r) => setTimeout(r, rand(3000, 5000))); - sendOk(res); - setTimeout(() => { - bootTime = Date.now(); - }, 2000); + initScenario(scenarioFromRequest(parsed.searchParams, req.headers.cookie)); + const response = await api.handle(createRequestAdapter(req, parsed)); + if (response === false) return false; + if (response.offline) { + req.destroy(); return; } - - // PUT /api/settings — partial update, simulate NVS write delay - if (req.method === "PUT" && path === "/api/settings") { - if (faults.settings) { - await new Promise((r) => setTimeout(r, rand(200, 400))); - return sendError(res, "NVS write failed: flash error", 500); - } - const body = await readBody(req); - try { - const data = JSON.parse(body); - await new Promise((r) => setTimeout(r, rand(300, 600))); - if (data.tz !== undefined) state.tz = data.tz; - if (data.logLevel !== undefined) state.logLevel = data.logLevel; - if (data.syslogEnabled !== undefined) state.syslogEnabled = data.syslogEnabled; - if (data.syslogIp !== undefined) state.syslogIp = data.syslogIp; - if (data.wifiTxPower !== undefined) state.wifiTxPower = data.wifiTxPower; - const pinsChanged = - (data.uartTxPin !== undefined && data.uartTxPin !== state.uartTxPin) || - (data.uartRxPin !== undefined && data.uartRxPin !== state.uartRxPin); - if (data.uartTxPin !== undefined) state.uartTxPin = data.uartTxPin; - if (data.uartRxPin !== undefined) state.uartRxPin = data.uartRxPin; - const hostnameChanged = data.hostname !== undefined && data.hostname !== state.hostname; - if (data.hostname !== undefined) state.hostname = data.hostname; - if (data.navMode !== undefined) state.navMode = data.navMode; - if (data.stallThreshold !== undefined) state.stallThreshold = data.stallThreshold; - if (data.brushRpm !== undefined) state.brushRpm = data.brushRpm; - if (data.vacuumSpeed !== undefined) state.vacuumSpeed = data.vacuumSpeed; - if (data.sideBrushPower !== undefined) state.sideBrushPower = data.sideBrushPower; - if (data.ntfyTopic !== undefined) state.ntfyTopic = data.ntfyTopic; - if (data.ntfyServer !== undefined) state.ntfyServer = data.ntfyServer; - if (data.ntfyToken !== undefined) state.ntfyToken = data.ntfyToken; - if (data.ntfyEnabled !== undefined) state.ntfyEnabled = data.ntfyEnabled; - if (data.ntfyOnStart !== undefined) state.ntfyOnStart = data.ntfyOnStart; - if (data.ntfyOnDone !== undefined) state.ntfyOnDone = data.ntfyOnDone; - if (data.ntfyOnError !== undefined) state.ntfyOnError = data.ntfyOnError; - if (data.ntfyOnAlert !== undefined) state.ntfyOnAlert = data.ntfyOnAlert; - if (data.ntfyOnDocking !== undefined) state.ntfyOnDocking = data.ntfyOnDocking; - if (data.scheduleEnabled !== undefined) state.scheduleEnabled = data.scheduleEnabled; - for (let d = 0; d < 7; d++) { - if (data[`sched${d}Hour`] !== undefined) state[`sched${d}Hour`] = data[`sched${d}Hour`]; - if (data[`sched${d}Min`] !== undefined) state[`sched${d}Min`] = data[`sched${d}Min`]; - if (data[`sched${d}On`] !== undefined) state[`sched${d}On`] = data[`sched${d}On`]; - if (data[`sched${d}Slot1Hour`] !== undefined) state[`sched${d}Slot1Hour`] = data[`sched${d}Slot1Hour`]; - if (data[`sched${d}Slot1Min`] !== undefined) state[`sched${d}Slot1Min`] = data[`sched${d}Slot1Min`]; - if (data[`sched${d}Slot1On`] !== undefined) state[`sched${d}Slot1On`] = data[`sched${d}Slot1On`]; - } - if (pinsChanged || hostnameChanged) { - setTimeout(() => { - bootTime = Date.now(); - }, 2000); - } - // Return full settings (reuse GET handler logic) - const s = {}; - const keys = [ - "tz", - "logLevel", - "syslogEnabled", - "syslogIp", - "wifiTxPower", - "uartTxPin", - "uartRxPin", - "maxGpioPin", - "hostname", - "stallThreshold", - "brushRpm", - "vacuumSpeed", - "sideBrushPower", - "ntfyTopic", - "ntfyServer", - "ntfyToken", - "ntfyEnabled", - "ntfyOnStart", - "ntfyOnDone", - "ntfyOnError", - "ntfyOnAlert", - "ntfyOnDocking", - "scheduleEnabled", - ]; - for (const k of keys) s[k] = state[k]; - for (let d = 0; d < 7; d++) { - s[`sched${d}Hour`] = state[`sched${d}Hour`]; - s[`sched${d}Min`] = state[`sched${d}Min`]; - s[`sched${d}On`] = state[`sched${d}On`]; - s[`sched${d}Slot1Hour`] = state[`sched${d}Slot1Hour`]; - s[`sched${d}Slot1Min`] = state[`sched${d}Slot1Min`]; - s[`sched${d}Slot1On`] = state[`sched${d}Slot1On`]; - } - return jsonResponse(res, s); - } catch { - return sendError(res, "invalid JSON", 400); - } - } - - // Serial endpoint — always available (no log level gate) - if (req.method === "POST" && path === "/api/serial") { - const cmd = query.cmd; - if (!cmd) return sendError(res, "missing cmd", 400); - // Simulate serial response with a delay - await new Promise((r) => setTimeout(r, rand(50, 150))); - const body = `${cmd}\r\nMock response for: ${cmd}\r\n\x1a`; - res.writeHead(200, { "Content-Type": "text/plain", "Content-Length": Buffer.byteLength(body) }); - return res.end(body); - } - - // Standard route lookup - const key = `${req.method} ${path}`; - const handler = routes[key]; - if (handler) { - return handler(req, res, query); - } - - // Not an API route — return false so Vite can handle it - return false; + sendResponse(res, response); }; -// --- Vite plugin --- -// Hooks into Vite's dev server middleware so /api/* requests are handled -// in-process — single port, single `npm run dev` - function mockApiPlugin() { return { name: "mock-api", - // Clear update localStorage when not in upd scenario transformIndexHtml(html) { - if (SCENARIO.split("|").includes("upd")) return html; return html.replace( "", - ``, + ``, ); }, configureServer(server) { viteLogger = server.config.logger; server.middlewares.use(async (req, res, next) => { - // Only intercept /api/* and /repos/* (GitHub API mock) requests if (!req.url.startsWith("/api") && !req.url.startsWith("/repos")) return next(); logRequest(req, res); - - // Latency simulation (50-200ms) - await new Promise((r) => setTimeout(r, rand(50, 200))); + await new Promise((resolve) => setTimeout(resolve, rand(50, 200))); const handled = await handleRequest(req, res); if (handled === false) next(); @@ -1194,4 +200,4 @@ function mockApiPlugin() { }; } -module.exports = { mockApiPlugin }; +export { mockApiPlugin }; diff --git a/frontend/mock/shared-api.js b/frontend/mock/shared-api.js new file mode 100644 index 0000000..a3a3f07 --- /dev/null +++ b/frontend/mock/shared-api.js @@ -0,0 +1,735 @@ +const textEncoder = new TextEncoder(); + +const defaultRand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const jsonResponse = (data, status = 200) => ({ + status, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), +}); + +const textResponse = (body, status = 200, headers = {}) => ({ status, headers, body }); +const okResponse = () => jsonResponse({ ok: true }); +const errorResponse = (message, status = 500) => jsonResponse({ error: message }, status); + +const byteLength = (value) => textEncoder.encode(value).length; +const vBattFromFuel = (fuel) => Number((12.0 + (fuel / 100) * 4.6).toFixed(2)); +const isSafeFilename = (name) => /^[A-Za-z0-9._-]+$/.test(name) && !name.includes(".."); + +const mockLogs = [ + { name: "current.jsonl", size: 8192, compressed: false }, + { name: "1700000000.jsonl.hs", size: 4096, compressed: true }, + { name: "boot_2501.jsonl.hs", size: 1851, compressed: true }, +]; + +const mockLogContent = [ + '{"t":1700000100,"typ":"boot","d":{"reason":"power_on","heap":195000}}', + '{"t":1700000101,"typ":"wifi","d":{"event":"connected","rssi":-52}}', + '{"t":1700000102,"typ":"ntp","d":{"event":"sync_ok","epoch":1700000102}}', + '{"t":1700000200,"typ":"command","d":{"cmd":"GetCharger","status":"ok","ms":85,"q":0,"bytes":312,"resp":"GetCharger\\r\\nLabel,Value\\r\\nFuelPercent,85\\r\\nBatteryOverTemp,0\\r\\nChargingActive,0\\r\\nChargingEnabled,1\\r\\n"}}', + '{"t":1700000202,"typ":"command","d":{"cmd":"GetState","status":"ok","ms":42,"q":0,"bytes":95,"resp":"GetState\\r\\nCurrent UI State is: UIMGR_STATE_IDLE\\nCurrent Robot State is: ST_C_Idle\\n"}}', + '{"t":1700000203,"typ":"command","d":{"cmd":"GetState","status":"ok","ms":0,"q":0,"bytes":0,"age":1}}', + '{"t":1700000210,"typ":"request","d":{"method":"GET","path":"/api/charger","status":200,"ms":92}}', + '{"t":1700000215,"typ":"command","d":{"cmd":"GetErr","status":"ok","ms":38,"q":0,"bytes":64,"resp":"GetErr\\r\\nError\\r\\n200 - (UI_ALERT_INVALID)\\r\\nAlert\\r\\n200 - (UI_ALERT_INVALID)\\r\\n"}}', + '{"t":1700000216,"typ":"command","d":{"cmd":"GetErr","status":"ok","ms":0,"q":0,"bytes":0,"age":1}}', + '{"t":1700000220,"typ":"request","d":{"method":"GET","path":"/api/state","status":200,"ms":48}}', + '{"t":1700000300,"typ":"command","d":{"cmd":"GetAnalogSensors","status":"ok","ms":120,"q":1,"bytes":480}}', + '{"t":1700000305,"typ":"request","d":{"method":"POST","path":"/api/clean?action=house","status":200,"ms":210}}', + '{"t":1700000306,"typ":"command","d":{"cmd":"Clean House","status":"ok","ms":95,"q":0,"bytes":28}}', + '{"t":1700000400,"typ":"event","d":{"msg":"cleaning_started","mode":"house"}}', + '{"t":1700000500,"typ":"command","d":{"cmd":"GetMotors","status":"ok","ms":110,"q":2,"bytes":520}}', + '{"t":1700000600,"typ":"command","d":{"cmd":"GetLDSScan","status":"ok","ms":450,"q":0,"bytes":8200}}', + '{"t":1700000601,"typ":"command","d":{"cmd":"GetCharger","status":"ok","ms":0,"q":0,"bytes":0,"age":401}}', + '{"t":1700000615,"typ":"request","d":{"method":"GET","path":"/api/lidar","status":200,"ms":460}}', + '{"t":1700000620,"typ":"command","d":{"cmd":"GetLDSScan","status":"ok","ms":440,"q":1,"bytes":8140}}', + '{"t":1700000621,"typ":"command","d":{"cmd":"GetLDSScan","status":"ok","ms":0,"q":0,"bytes":0,"age":1}}', + '{"t":1700000700,"typ":"event","d":{"msg":"cleaning_completed","duration":600}}', +].join("\n"); + +const syntheticLidarScan = (rand) => { + const points = []; + for (let i = 0; i < 360; i++) { + const rad = (i * Math.PI) / 180; + const base = 1500 + Math.round(Math.sin(rad * 2) * 250) + rand(-40, 40); + points.push({ angle: i, dist: Math.max(0, base), intensity: rand(20, 70), error: 0 }); + } + return { rotationSpeed: 5, validPoints: points.length, points }; +}; + +const deriveStates = (state) => { + if (state.manualClean) { + state.uiState = "UIMGR_STATE_MANUALCLEANING"; + state.robotState = "ST_C_ManualCleaning"; + } else if (state.midCleanRecharge) { + state.uiState = "UIMGR_STATE_CLEANINGSUSPENDED"; + state.robotState = "ST_M1_Charging_Cleaning"; + } else if (state.docking) { + state.uiState = "UIMGR_STATE_DOCKINGRUNNING"; + state.robotState = "ST_C_Docking"; + } else if (state.cleaning && !state.paused) { + state.uiState = "UIMGR_STATE_HOUSECLEANINGRUNNING"; + state.robotState = "ST_C_HouseCleaning"; + } else if (state.cleaning && state.paused) { + state.uiState = "UIMGR_STATE_HOUSECLEANINGPAUSED"; + state.robotState = "ST_C_Standby"; + } else if (state.spotCleaning && !state.paused) { + state.uiState = "UIMGR_STATE_SPOTCLEANINGRUNNING"; + state.robotState = "ST_C_SpotCleaning"; + } else if (state.spotCleaning && state.paused) { + state.uiState = "UIMGR_STATE_SPOTCLEANINGPAUSED"; + state.robotState = "ST_C_Standby"; + } else { + state.uiState = "UIMGR_STATE_IDLE"; + state.robotState = "ST_C_Idle"; + } +}; + +const settingsPayload = (state, includeNavMode = true) => { + const settings = {}; + const keys = [ + "tz", + "logLevel", + "apFallbackOnDisconnect", + "syslogEnabled", + "syslogIp", + "wifiTxPower", + "uartTxPin", + "uartRxPin", + "maxGpioPin", + "hostname", + ...(includeNavMode ? ["navMode"] : []), + "stallThreshold", + "brushRpm", + "vacuumSpeed", + "sideBrushPower", + "ntfyTopic", + "ntfyServer", + "ntfyToken", + "ntfyEnabled", + "ntfyOnStart", + "ntfyOnDone", + "ntfyOnError", + "ntfyOnAlert", + "ntfyOnDocking", + "scheduleEnabled", + ]; + for (const key of keys) settings[key] = state[key]; + for (let day = 0; day < 7; day++) { + settings[`sched${day}Hour`] = state[`sched${day}Hour`]; + settings[`sched${day}Min`] = state[`sched${day}Min`]; + settings[`sched${day}On`] = state[`sched${day}On`]; + settings[`sched${day}Slot1Hour`] = state[`sched${day}Slot1Hour`]; + settings[`sched${day}Slot1Min`] = state[`sched${day}Slot1Min`]; + settings[`sched${day}Slot1On`] = state[`sched${day}Slot1On`]; + } + return settings; +}; + +const injectCorruptedPoses = (lines) => { + const corruptions = [ + '{"x":-0.798,"y":3.459,"t":100:5,"ts":8203.4}', + '{"x":-0.785,"y":4.192,"t":100:3,"ts":8208.1}', + '{"x":-1.286,"y":4.007,"t":177:5,"ts":8215.6}', + '{"x":-1.8"3,"y":2.254,"t":181.0,"ts":8288.6}', + '{"x":-1.946,"y":2.0"3,"t":181.9,"ts":8312.8}', + '{"x":-4.793,"y":-1.6t2,"t":356.5,"ts":9182.5}', + '{"x":-1.740,"y":3.510,"t":1.6,"ts":8242:0}', + ]; + const result = [lines[0]]; + for (let i = 1; i < lines.length; i++) { + result.push(lines[i]); + if (!lines[i].includes('"type"') && i % 20 === 0) result.push(corruptions[i % corruptions.length]); + } + return result; +}; + +const listHistory = (historySessions, faults) => { + const list = [...historySessions.entries()].map(([name, lines]) => { + let session = null; + let summary = null; + if (lines.length > 0) { + try { + const first = JSON.parse(lines[0]); + if (first.type === "session") session = first; + } catch {} + } + if (lines.length > 1) { + try { + const last = JSON.parse(lines[lines.length - 1]); + if (last.type === "summary") summary = last; + } catch {} + } + const raw = `${lines.join("\n")}\n`; + return { + name, + size: byteLength(raw), + compressed: name.endsWith(".hs"), + recording: summary === null, + session, + summary, + }; + }); + + if (faults.historyListCorrupt && list.length > 0) { + const target = list[Math.min(1, list.length - 1)]; + const summaryJson = target.summary ? JSON.stringify(target.summary) : '{"type":"summary"}'; + const corruptedSummary = `{"x":-0.218,"y":0.007,"t":35${summaryJson.slice(1)}`; + return textResponse( + JSON.stringify(list).replace(JSON.stringify(target.summary ?? null), corruptedSummary), + 200, + { + "Content-Type": "application/json", + }, + ); + } + + return jsonResponse(list); +}; + +const parseMultipartJsonlUpload = (bodyBytes) => { + const bodyStr = new TextDecoder("latin1").decode(bodyBytes); + const nameMatch = bodyStr.match(/filename="([^"]+)"/); + if (!nameMatch?.[1].endsWith(".jsonl") || !isSafeFilename(nameMatch[1])) { + return { error: "Invalid file: expected a .jsonl session file", status: 400 }; + } + const headerEnd = bodyStr.indexOf("\r\n\r\n"); + const boundaryEnd = bodyStr.lastIndexOf("\r\n--"); + if (headerEnd < 0 || boundaryEnd < 0) return { error: "Malformed multipart body", status: 400 }; + + const filename = nameMatch[1]; + const content = new TextDecoder().decode(bodyBytes.slice(headerEnd + 4, boundaryEnd)); + const lines = content + .trim() + .split("\n") + .filter((line) => line.length > 0); + if (lines.length === 0) return { error: "Empty session file", status: 400 }; + + try { + const header = JSON.parse(lines[0]); + if (header.type !== "session") return { error: "First line must be a session header", status: 400 }; + const fileEpoch = filename.replace(".jsonl", ""); + if (header.time !== undefined && String(header.time) !== fileEpoch) { + return { error: "Session timestamp does not match filename", status: 400 }; + } + } catch { + return { error: "Invalid JSON in session header", status: 400 }; + } + + return { filename, lines }; +}; + +const extractFirmwarePayload = (bodyBytes) => { + const bodyStr = new TextDecoder("latin1").decode(bodyBytes); + const headerEnd = bodyStr.indexOf("\r\n\r\n"); + const boundaryEnd = bodyStr.lastIndexOf("\r\n--"); + const fileStart = headerEnd !== -1 ? headerEnd + 4 : -1; + const fileEnd = boundaryEnd > fileStart ? boundaryEnd : bodyBytes.length; + if (fileStart === -1) return null; + return bodyBytes.slice(fileStart, fileEnd); +}; + +function createMockApi(context) { + const rand = context.rand ?? defaultRand; + const sleep = context.sleep ?? defaultSleep; + + const getState = () => context.state; + const getFaults = () => context.faults; + + const handle = async (request) => { + const state = getState(); + const faults = getFaults(); + const method = request.method; + const path = request.path; + const query = request.query; + + if (state.offline) return { offline: true }; + + if (method === "GET" && path === "/api/version") { + return jsonResponse({ + modelName: "BotVacD7", + serialNumber: "OPS01234AA,0000001,D", + softwareVersion: "4.5.3-142", + ldsVersion: "V2.6.15295", + ldsSerial: "KSH-V5F4", + mainBoardVersion: "15.0", + smartBatteryAuthorization: 1, + smartBatteryDataVersion: 512, + smartBatteryChemistry: "LION1", + smartBatteryDeviceName: "F164A10288", + smartBatteryManufacturerName: "Panasonic", + smartBatteryMfgDate: "2089-02-18", + smartBatterySerialNumber: "34832", + smartBatterySoftwareVersion: "2048", + }); + } + + if (method === "GET" && path === "/api/charger") { + if (faults.pollCharger) return errorResponse("UART timeout reading charger", 500); + const fuel = Math.round(state.fuelPercent); + return jsonResponse({ + fuelPercent: fuel, + batteryOverTemp: false, + chargingActive: state.chargingActive, + chargingEnabled: true, + confidOnFuel: fuel > 20, + onReservedFuel: fuel < 10, + emptyFuel: fuel === 0, + batteryFailure: false, + extPwrPresent: state.extPwrPresent, + vBattV: vBattFromFuel(fuel), + vExtV: state.extPwrPresent ? 22.3 : 0.0, + chargerMAH: state.chargingActive ? 1200 : 0, + dischargeMAH: Math.round(((100 - fuel) / 100) * 2800), + }); + } + + if (method === "GET" && path === "/api/analog") { + if (faults.pollCharger) return errorResponse("UART timeout reading battery diagnostics", 500); + const fuel = Math.round(state.fuelPercent); + const batteryCurrentMA = state.chargingActive + ? rand(180, 720) + : state.extPwrPresent + ? rand(-20, 20) + : -rand(80, 420); + return jsonResponse({ + batteryVoltageV: vBattFromFuel(fuel), + batteryCurrentMA, + batteryTemperatureC: Number((24 + (state.chargingActive ? 1.8 : 0) + Math.random()).toFixed(1)), + externalVoltageV: state.extPwrPresent ? 18.66 : 0.0, + }); + } + + if (method === "GET" && path === "/api/warranty") { + return jsonResponse({ + cumulativeBatteryCycles: 728, + cumulativeCleaningTimeSeconds: 885884, + validationCode: "b40b2e9a", + }); + } + + if (method === "POST" && path === "/api/battery/new") return okResponse(); + + if (method === "GET" && path === "/api/motors") { + const cleaning = state.cleaning || state.spotCleaning; + return jsonResponse({ + brushRPM: cleaning ? rand(1100, 1300) : 0, + brushMA: cleaning ? rand(200, 400) : 0, + vacuumRPM: cleaning ? rand(2200, 2600) : 0, + vacuumMA: cleaning ? rand(400, 700) : 0, + leftWheelRPM: cleaning ? rand(60, 120) : 0, + leftWheelLoad: cleaning ? rand(10, 40) : 0, + leftWheelPositionMM: state.leftWheelPos, + leftWheelSpeed: cleaning ? rand(150, 300) : 0, + rightWheelRPM: cleaning ? rand(60, 120) : 0, + rightWheelLoad: cleaning ? rand(10, 40) : 0, + rightWheelPositionMM: state.rightWheelPos, + rightWheelSpeed: cleaning ? rand(150, 300) : 0, + sideBrushMA: cleaning ? rand(50, 200) : 0, + laserRPM: cleaning ? rand(290, 310) : 0, + }); + } + + if (method === "GET" && path === "/api/state") { + if (faults.pollState) return errorResponse("UART timeout reading state", 500); + deriveStates(state); + return jsonResponse({ uiState: state.uiState, robotState: state.robotState }); + } + + if (method === "GET" && path === "/api/error") { + if (faults.pollError) return errorResponse("UART timeout reading error", 500); + return jsonResponse({ + hasError: state.hasError, + kind: state.kind, + errorCode: state.errorCode, + errorMessage: state.errorMessage, + displayMessage: state.displayMessage, + }); + } + + if (method === "GET" && path === "/api/lidar") { + if (state.lidarUnavailable) return errorResponse("UART timeout reading LDS scan", 500); + const scan = context.getLidarScan ? context.getLidarScan() : syntheticLidarScan(rand); + if (state.lidarLowQuality || state.lidarSlowRotation) { + const degraded = { ...scan }; + if (state.lidarSlowRotation) degraded.rotationSpeed = 2.8; + if (state.lidarLowQuality) { + degraded.points = scan.points.map((point, index) => + index % 5 === 0 ? point : { ...point, dist: 0, intensity: 0, error: 8035 }, + ); + degraded.validPoints = degraded.points.filter((point) => point.dist > 0).length; + } + return jsonResponse(degraded); + } + return jsonResponse(scan); + } + + if (method === "POST" && path === "/api/clean") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + const action = query.action || "house"; + if (action === "dock") { + if (state.cleaning || state.spotCleaning) { + state.docking = true; + state.cleaning = false; + state.spotCleaning = false; + state.paused = false; + } + } else if (action === "pause") { + if ((state.cleaning || state.spotCleaning) && !state.paused) state.paused = true; + } else if (action === "stop") { + state.cleaning = false; + state.spotCleaning = false; + state.docking = false; + state.paused = false; + } else if (action === "spot") { + state.spotCleaning = true; + state.cleaning = false; + state.docking = false; + state.paused = false; + } else { + state.cleaning = true; + state.spotCleaning = false; + state.docking = false; + state.paused = false; + } + deriveStates(state); + return okResponse(); + } + + if (method === "GET" && path === "/api/manual/status") { + return jsonResponse({ + active: state.manualClean, + brush: state.manualBrush, + vacuum: state.manualVacuum, + sideBrush: state.manualSideBrush, + lifted: state.manualLifted, + bumperFrontLeft: state.manualBumperFrontLeft, + bumperFrontRight: state.manualBumperFrontRight, + bumperSideLeft: state.manualBumperSideLeft, + bumperSideRight: state.manualBumperSideRight, + stallFront: state.manualStallFront, + stallRear: state.manualStallRear, + }); + } + + if (method === "POST" && path === "/api/manual/move") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + if (!state.manualClean) return errorResponse("Not in manual mode", 400); + return okResponse(); + } + + if (method === "POST" && path === "/api/manual/motors") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + if (!state.manualClean) return errorResponse("Not in manual mode", 400); + state.manualBrush = query.brush === "1"; + state.manualVacuum = query.vacuum === "1"; + state.manualSideBrush = query.sideBrush === "1"; + await sleep(600); + return okResponse(); + } + + if (method === "POST" && path === "/api/power") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + if (query.action === "restart") return okResponse(); + if (query.action === "shutdown") { + context.shutdown?.(); + return okResponse(); + } + return errorResponse("unknown action", 400); + } + + if (method === "POST" && path === "/api/clear-errors") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + return okResponse(); + } + + if (method === "POST" && path === "/api/sound") return okResponse(); + + if (method === "POST" && path === "/api/manual") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + const enable = query.enable === "1"; + state.manualClean = enable; + if (enable) { + state.cleaning = false; + state.spotCleaning = false; + state.paused = false; + } + state.manualBrush = false; + state.manualVacuum = false; + state.manualSideBrush = false; + if (!enable) { + state.manualLifted = false; + state.manualBumperFrontLeft = false; + state.manualBumperFrontRight = false; + state.manualBumperSideLeft = false; + state.manualBumperSideRight = false; + state.manualStallFront = false; + state.manualStallRear = false; + } + deriveStates(state); + return okResponse(); + } + + if (method === "POST" && path === "/api/lidar/rotate") { + if (faults.actions) return errorResponse("UART timeout: robot not responding", 500); + return okResponse(); + } + + if (method === "GET" && path === "/api/logs") { + if (faults.logsRead) return errorResponse("SPIFFS read failed", 500); + return jsonResponse(mockLogs); + } + + if (method === "DELETE" && path === "/api/logs") { + if (faults.logsDelete) return errorResponse("SPIFFS busy, try again later", 503); + return okResponse(); + } + + const logFileMatch = path.match(/^\/api\/logs\/(.+)$/); + if (logFileMatch) { + const filename = decodeURIComponent(logFileMatch[1]); + if (!isSafeFilename(filename)) return errorResponse("invalid filename", 400); + if (method === "GET") { + if (faults.logsRead) return errorResponse("SPIFFS read failed", 500); + return textResponse(mockLogContent, 200, { + "Content-Type": "application/x-ndjson", + "Content-Disposition": `attachment; filename="${filename.replace(/\.hs$/, "")}"`, + }); + } + if (method === "DELETE") { + if (faults.logsDelete) return errorResponse("SPIFFS busy, try again later", 503); + return okResponse(); + } + return errorResponse("method not allowed", 405); + } + + if (method === "GET" && path === "/api/system") { + const now = new Date(); + const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const pad = (number) => number.toString().padStart(2, "0"); + const localTime = `${days[now.getDay()]} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; + return jsonResponse({ + heap: rand(160000, 200000), + heapTotal: 327680, + uptime: Date.now() - context.getBootTime(), + rssi: rand(-65, -40), + fsUsed: rand(10000, 50000), + fsTotal: 262144, + ntpSynced: true, + time: Math.floor(Date.now() / 1000), + timeSource: "ntp", + tz: state.tz, + localTime, + isDst: now.getTimezoneOffset() !== new Date(now.getFullYear(), 0, 1).getTimezoneOffset(), + }); + } + + if (method === "POST" && ["/api/system/restart", "/api/system/format-fs", "/api/system/reset"].includes(path)) { + context.reboot?.(); + return okResponse(); + } + + if (method === "GET" && path === "/api/settings") return jsonResponse(settingsPayload(state)); + + if (method === "PUT" && path === "/api/settings") { + if (faults.settings) { + await sleep(rand(200, 400)); + return errorResponse("NVS write failed: flash error", 500); + } + try { + const data = JSON.parse(await request.text()); + await sleep(rand(300, 600)); + const oldTx = state.uartTxPin; + const oldRx = state.uartRxPin; + const oldHostname = state.hostname; + Object.assign(state, data); + if (state.uartTxPin !== oldTx || state.uartRxPin !== oldRx || state.hostname !== oldHostname) + context.reboot?.(); + return jsonResponse(settingsPayload(state, false)); + } catch { + return errorResponse("invalid JSON", 400); + } + } + + if (method === "GET" && path === "/api/wifi/status") { + const hasCreds = !state.wifiNoCredentials; + const apOn = !!state.wifiDisconnected || !hasCreds; + return jsonResponse({ + staConnected: !state.wifiDisconnected && hasCreds, + ssid: hasCreds ? "HomeWiFi" : "", + ip: state.wifiDisconnected || !hasCreds ? "" : "192.168.1.42", + rssi: state.wifiDisconnected || !hasCreds ? 0 : -52, + apActive: apOn, + apSsid: apOn ? `${state.hostname}-ap` : "", + apIp: apOn ? "192.168.4.1" : "", + apClients: apOn ? (hasCreds ? 1 : 0) : 0, + apFallbackOnDisconnect: state.apFallbackOnDisconnect, + lastError: state.wifiDisconnected && hasCreds ? "wrong password or authentication rejected" : "", + }); + } + + if (method === "GET" && path === "/api/wifi/scan") { + await sleep(rand(800, 1500)); + if (faults.wifiScan) return errorResponse("WiFi scan failed: radio busy", 500); + if (faults.wifiScanEmpty) return jsonResponse({ networks: [] }); + return jsonResponse({ + networks: [ + { ssid: "HomeWiFi", rssi: -52, open: false }, + { ssid: "Neighbour-5G", rssi: -68, open: false }, + { ssid: "GuestNet", rssi: -71, open: true }, + { ssid: "FritzBox-2", rssi: -78, open: false }, + { ssid: "TP-Link-Guest", rssi: -82, open: true }, + ], + }); + } + + if (method === "POST" && path === "/api/wifi/connect") { + if (!query.ssid) return errorResponse("missing ssid", 400); + await sleep(rand(500, 1500)); + if (faults.wifiConnect) return errorResponse("wrong password or authentication rejected", 500); + state.wifiDisconnected = false; + state.wifiNoCredentials = false; + return okResponse(); + } + + if (method === "POST" && path === "/api/wifi/disconnect") { + state.wifiDisconnected = true; + state.wifiNoCredentials = true; + return okResponse(); + } + + if (method === "POST" && path === "/api/notifications/test") { + if (!query.topic) return errorResponse("missing topic", 400); + return okResponse(); + } + + if (method === "GET" && path === "/repos/renjfk/OpenNeato/releases/latest") + return jsonResponse({ tag_name: "v1.0" }); + + if (method === "GET" && path === "/api/user-settings") { + return jsonResponse({ + buttonClick: state.buttonClick, + melodies: state.melodies, + warnings: state.warnings, + ecoMode: state.ecoMode, + intenseClean: state.intenseClean, + binFullDetect: state.binFullDetect, + wallEnable: state.wallEnable, + wifi: state.wifi, + stealthLed: state.stealthLed, + filterChange: state.filterChange, + brushChange: state.brushChange, + dirtBin: state.dirtBin, + }); + } + + if (method === "POST" && path === "/api/user-settings") { + const keyMap = { + ButtonClick: "buttonClick", + Melodies: "melodies", + Warnings: "warnings", + EcoMode: "ecoMode", + IntenseClean: "intenseClean", + BinFullDetect: "binFullDetect", + WallEnable: "wallEnable", + WiFi: "wifi", + StealthLED: "stealthLed", + FilterChange: "filterChange", + BrushChange: "brushChange", + DirtBin: "dirtBin", + }; + const serialKey = query.key; + const value = query.value; + if (!serialKey || !value) return errorResponse("missing key or value", 400); + const stateKey = keyMap[serialKey]; + if (!stateKey) return errorResponse("unknown key", 400); + state[stateKey] = ["filterChange", "brushChange", "dirtBin"].includes(stateKey) + ? Number.parseInt(value, 10) + : value.toUpperCase() === "ON"; + return okResponse(); + } + + if (method === "GET" && path === "/api/firmware/version") { + const version = state.firmwareVersion ?? (await context.getVersion()); + return jsonResponse({ + name: "OpenNeato", + version, + chip: "ESP32-C3", + model: state.identifying ? "" : state.unsupported ? "Botvac Connected" : "Botvac D7", + hostname: "neato-kitchen", + supported: !state.unsupported && !state.identifying, + identifying: state.identifying, + repositoryUrl: "https://github.com/renjfk/OpenNeato", + license: "MIT", + }); + } + + if (method === "POST" && path === "/api/firmware/update") { + const expectedMd5 = query.hash || ""; + if (!expectedMd5) return errorResponse("MD5 hash required", 400); + const bodyBytes = await request.bytes(); + const fileBytes = extractFirmwarePayload(bodyBytes); + if (fileBytes) { + const mockChipId = 5; + if (fileBytes.length >= 16 && fileBytes[12] !== mockChipId) { + return errorResponse("Firmware chip mismatch: file targets a different ESP32 variant", 400); + } + if (!context.md5Hex) return errorResponse("MD5 verification unavailable", 500); + const actualMd5 = await context.md5Hex(fileBytes); + if (actualMd5 !== expectedMd5.toLowerCase()) + return errorResponse("MD5 mismatch: firmware integrity check failed", 400); + } + await sleep(rand(3000, 5000)); + context.reboot?.(); + return okResponse(); + } + + if (method === "POST" && path === "/api/serial") { + const cmd = query.cmd; + if (!cmd) return errorResponse("missing cmd", 400); + await sleep(rand(50, 150)); + return textResponse(`${cmd}\r\nMock response for: ${cmd}\r\n\x1a`, 200, { "Content-Type": "text/plain" }); + } + + if (method === "GET" && path === "/api/history") return listHistory(context.historySessions, faults); + + if (method === "DELETE" && path === "/api/history") { + context.historySessions.clear(); + return okResponse(); + } + + if (method === "POST" && path === "/api/history/import") { + const parsed = parseMultipartJsonlUpload(await request.bytes()); + if (parsed.error) return errorResponse(parsed.error, parsed.status); + const storedName = `${parsed.filename}.hs`; + if (context.historySessions.has(storedName) || context.historySessions.has(parsed.filename)) { + return errorResponse("Session already exists", 409); + } + context.historySessions.set(storedName, parsed.lines); + await sleep(rand(200, 500)); + return okResponse(); + } + + const historyMatch = path.match(/^\/api\/history\/(.+)$/); + if (historyMatch) { + const filename = decodeURIComponent(historyMatch[1]); + if (method === "GET") { + const lines = context.historySessions.get(filename); + if (!lines) return errorResponse("session not found", 404); + const served = faults.historyCorrupt ? injectCorruptedPoses(lines) : lines; + return textResponse(`${served.join("\n")}\n`, 200, { "Content-Type": "application/x-ndjson" }); + } + if (method === "DELETE") { + context.historySessions.delete(filename); + return okResponse(); + } + return errorResponse("method not allowed", 405); + } + + return false; + }; + + return { handle }; +} + +export { createMockApi }; diff --git a/frontend/mock/shared-state.js b/frontend/mock/shared-state.js new file mode 100644 index 0000000..0455d46 --- /dev/null +++ b/frontend/mock/shared-state.js @@ -0,0 +1,254 @@ +const SCENARIOS = { + ok: {}, + off: { offline: true }, + cls: { cleaning: true }, + spt: { spotCleaning: true }, + chg: { fuelPercent: 62, chargingActive: true, extPwrPresent: true }, + ch2: { fuelPercent: 25, chargingActive: true, extPwrPresent: true }, + ful: { fuelPercent: 100, chargingActive: false, extPwrPresent: true }, + mid: { fuelPercent: 45 }, + low: { fuelPercent: 12 }, + ded: { fuelPercent: 0 }, + err: { + hasError: true, + kind: "error", + errorCode: 265, + errorMessage: + "Error\r\n265 - (UI_ERROR_BRUSH_STUCK)\r\nAlert\r\n205 - (UI_ALERT_DUST_BIN_FULL)\r\nUSB state \r\n NOT connected", + displayMessage: "Main brush is stuck", + }, + alrt: { + hasError: true, + kind: "warning", + errorCode: 229, + errorMessage: "Error\r\n200 - (UI_ALERT_INVALID)\r\nAlert\r\n229 - (UI_ALERT_BRUSH_CHANGE)", + displayMessage: "Time to replace the brush", + }, + dock: { docking: true, cleaning: false }, + rchg: { + midCleanRecharge: true, + fuelPercent: 15, + chargingActive: true, + extPwrPresent: true, + }, + man: { manualClean: true }, + mlf: { manualClean: true, manualLifted: true }, + mbf: { manualClean: true, manualBumperFrontLeft: true }, + mbs: { manualClean: true, manualBumperSideRight: true }, + msf: { manualClean: true, manualStallFront: true }, + msr: { manualClean: true, manualStallRear: true }, + ident: { identifying: true }, + unsup: { unsupported: true }, + upd: { firmwareVersion: "0.9" }, + llq: { lidarLowQuality: true }, + lsl: { lidarSlowRotation: true }, + lno: { lidarUnavailable: true }, + fa: { faults: { actions: true } }, + fs: { faults: { settings: true } }, + flr: { faults: { logsRead: true } }, + fld: { faults: { logsDelete: true } }, + fl: { faults: { logsRead: true, logsDelete: true } }, + fps: { faults: { pollState: true } }, + fpc: { faults: { pollCharger: true } }, + fpe: { faults: { pollError: true } }, + fp: { faults: { pollState: true, pollCharger: true, pollError: true } }, + fhc: { faults: { historyCorrupt: true } }, + fhl: { faults: { historyListCorrupt: true } }, + wap: { wifiDisconnected: true }, + wnc: { wifiDisconnected: true, wifiNoCredentials: true }, + wfo: { apFallbackOnDisconnect: false }, + fws: { faults: { wifiScan: true } }, + fwn: { faults: { wifiScanEmpty: true } }, + fwc: { faults: { wifiConnect: true } }, + fal: { + faults: { + actions: true, + settings: true, + logsRead: true, + logsDelete: true, + pollState: true, + pollCharger: true, + pollError: true, + historyCorrupt: true, + historyListCorrupt: true, + wifiScan: true, + wifiConnect: true, + }, + }, +}; + +const DEFAULT_STATE = { + offline: false, + fuelPercent: 85, + chargingActive: false, + extPwrPresent: false, + cleaning: false, + spotCleaning: false, + docking: false, + paused: false, + uiState: "UIMGR_STATE_IDLE", + robotState: "ST_C_Idle", + hasError: false, + kind: "", + errorCode: 200, + errorMessage: "", + displayMessage: "", + manualClean: false, + manualBrush: false, + manualVacuum: false, + manualSideBrush: false, + manualLifted: false, + manualBumperFrontLeft: false, + manualBumperFrontRight: false, + manualBumperSideLeft: false, + manualBumperSideRight: false, + manualStallFront: false, + manualStallRear: false, + midCleanRecharge: false, + identifying: false, + unsupported: false, + firmwareVersion: null, + lidarLowQuality: false, + lidarSlowRotation: false, + lidarUnavailable: false, + tz: "UTC0", + logLevel: 0, + apFallbackOnDisconnect: true, + wifiDisconnected: false, + wifiNoCredentials: false, + syslogEnabled: false, + syslogIp: "", + wifiTxPower: 60, + uartTxPin: 3, + uartRxPin: 4, + maxGpioPin: 21, + hostname: "neato", + navMode: "Normal", + stallThreshold: 60, + brushRpm: 1200, + vacuumSpeed: 80, + sideBrushPower: 1500, + ntfyTopic: "", + ntfyServer: "", + ntfyToken: "", + ntfyEnabled: true, + ntfyOnStart: true, + ntfyOnDone: true, + ntfyOnError: true, + ntfyOnAlert: true, + ntfyOnDocking: true, + buttonClick: true, + melodies: true, + warnings: true, + ecoMode: false, + intenseClean: false, + binFullDetect: true, + wallEnable: true, + wifi: true, + stealthLed: false, + filterChange: 2592000, + brushChange: 2592000, + dirtBin: 30, + scheduleEnabled: true, + sched0Hour: 9, + sched0Min: 0, + sched0On: true, + sched1Hour: 9, + sched1Min: 0, + sched1On: true, + sched2Hour: 9, + sched2Min: 0, + sched2On: true, + sched3Hour: 9, + sched3Min: 0, + sched3On: true, + sched4Hour: 9, + sched4Min: 0, + sched4On: true, + sched5Hour: 0, + sched5Min: 0, + sched5On: false, + sched6Hour: 0, + sched6Min: 0, + sched6On: false, + sched0Slot1Hour: 15, + sched0Slot1Min: 0, + sched0Slot1On: true, + sched1Slot1Hour: 15, + sched1Slot1Min: 0, + sched1Slot1On: true, + sched2Slot1Hour: 15, + sched2Slot1Min: 0, + sched2Slot1On: true, + sched3Slot1Hour: 15, + sched3Slot1Min: 0, + sched3Slot1On: true, + sched4Slot1Hour: 15, + sched4Slot1Min: 0, + sched4Slot1On: true, + sched5Slot1Hour: 0, + sched5Slot1Min: 0, + sched5Slot1On: false, + sched6Slot1Hour: 0, + sched6Slot1Min: 0, + sched6Slot1On: false, +}; + +const DEFAULT_FAULTS = { + actions: false, + settings: false, + logsRead: false, + logsDelete: false, + pollState: false, + pollCharger: false, + pollError: false, + historyCorrupt: false, + historyListCorrupt: false, + wifiScan: false, + wifiScanEmpty: false, + wifiConnect: false, +}; + +const SCENARIO_COOKIE = "openneato_scenario"; + +function normalizeScenario(scenario) { + return (scenario || "ok").trim() || "ok"; +} + +function parseCookies(cookieHeader = "") { + const cookies = {}; + for (const part of cookieHeader.split(";")) { + const [rawName, ...rawValue] = part.trim().split("="); + if (!rawName) continue; + cookies[rawName] = decodeURIComponent(rawValue.join("=")); + } + return cookies; +} + +function scenarioFromRequest(query = {}, cookieHeader = "") { + const queryScenario = typeof query.get === "function" ? query.get("scenario") : query.scenario; + return normalizeScenario(queryScenario || parseCookies(cookieHeader)[SCENARIO_COOKIE]); +} + +function scenarioCookie(scenario) { + return `${SCENARIO_COOKIE}=${encodeURIComponent(normalizeScenario(scenario))}; Path=/; SameSite=Lax`; +} + +function createScenarioState(scenario = "ok") { + const merged = {}; + const mergedFaults = {}; + + for (const key of scenario.split("|")) { + const s = SCENARIOS[key] || {}; + const { faults, ...rest } = s; + Object.assign(merged, rest); + if (faults) Object.assign(mergedFaults, faults); + } + + return { + state: { ...DEFAULT_STATE, ...merged }, + faults: { ...DEFAULT_FAULTS, ...mergedFaults }, + }; +} + +export { createScenarioState, SCENARIOS, scenarioCookie, scenarioFromRequest }; diff --git a/frontend/mock/shared-version.js b/frontend/mock/shared-version.js new file mode 100644 index 0000000..d2c00b7 --- /dev/null +++ b/frontend/mock/shared-version.js @@ -0,0 +1,7 @@ +const DEFAULT_MOCK_VERSION = "0.0-dev"; + +function mockVersionFromHash(hash) { + return hash ? `0.0-${hash}` : DEFAULT_MOCK_VERSION; +} + +export { DEFAULT_MOCK_VERSION, mockVersionFromHash }; diff --git a/frontend/mock/worker.js b/frontend/mock/worker.js new file mode 100644 index 0000000..711bf6f --- /dev/null +++ b/frontend/mock/worker.js @@ -0,0 +1,415 @@ +import { DEMO_VERSION } from "./build-info.js"; +import mapdataEmpty04 from "./mapdata-empty-04.jsonl"; +import mapdataHouse03 from "./mapdata-house-03.jsonl"; +import mapdataSpot01 from "./mapdata-spot-01.jsonl"; +import mapdataSpot02 from "./mapdata-spot-02.jsonl"; +import { createMockApi } from "./shared-api.js"; +import { createScenarioState, scenarioCookie, scenarioFromRequest } from "./shared-state.js"; + +const UMAMI_ENDPOINT = "https://cloud.umami.is/api/send"; +const WEBSITE_ID = "417b882b-03c5-45d2-b070-9d7b8b7855d4"; +const SESSION_COOKIE = "openneato_demo_session"; +const MAX_BODY_BYTES = 64 * 1024; +const MAX_SESSIONS = 100; +const SESSION_TTL_MS = 60 * 60 * 1000; + +const randomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const json = (data, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); + +const err = (message, status = 500) => json({ error: message }, status); + +const SECURITY_HEADERS = { + "Content-Security-Policy": [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join("; "), + "Cross-Origin-Resource-Policy": "same-origin", + "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", +}; + +const withSecurityHeaders = (response) => { + const secured = new Response(response.body, response); + for (const [name, value] of Object.entries(SECURITY_HEADERS)) secured.headers.set(name, value); + return secured; +}; + +const parseCookieValue = (cookieHeader, name) => { + for (const part of cookieHeader.split(";")) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName !== name) continue; + try { + return decodeURIComponent(rawValue.join("=")); + } catch { + return ""; + } + } + return ""; +}; + +const sessionCookie = (id) => + `${SESSION_COOKIE}=${encodeURIComponent(id)}; Path=/; Max-Age=3600; SameSite=Lax; HttpOnly; Secure`; + +async function checkAnalyticsRateLimit(request, env) { + if (!env.ANALYTICS_RATE_LIMITER) return null; + + const cookieSession = parseCookieValue(request.headers.get("Cookie") ?? "", SESSION_COOKIE); + const clientId = cookieSession || request.headers.get("CF-Connecting-IP") || "anonymous"; + const { success } = await env.ANALYTICS_RATE_LIMITER.limit({ key: `collect:${clientId}` }); + if (success) return null; + + return new Response(null, { + status: 429, + headers: { "Retry-After": "60" }, + }); +} + +async function readBodyWithLimit(request) { + if (!request.body) return new Uint8Array(); + + const reader = request.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + throw new Error("Request body too large"); + } + chunks.push(value); + } + + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +const historyFixtures = [ + ["mapdata-empty-04.jsonl", mapdataEmpty04], + ["mapdata-house-03.jsonl", mapdataHouse03], + ["mapdata-spot-01.jsonl", mapdataSpot01], + ["mapdata-spot-02.jsonl", mapdataSpot02], +]; + +async function handleCollect(request, env) { + try { + const url = new URL(request.url); + const origin = request.headers.get("Origin"); + if (origin) { + let originHost = ""; + try { + originHost = new URL(origin).host; + } catch { + return new Response(null, { status: 403 }); + } + if (originHost !== url.host) return new Response(null, { status: 403 }); + } + const contentLength = Number(request.headers.get("Content-Length") || "0"); + if (contentLength > MAX_BODY_BYTES) return new Response(null, { status: 413 }); + + const rateLimited = await checkAnalyticsRateLimit(request, env); + if (rateLimited) return rateLimited; + + const formRequest = new Request(request.url, { + method: "POST", + headers: request.headers, + body: await readBodyWithLimit(request), + }); + const form = await formRequest.formData(); + const hostname = url.hostname; + + const response = await fetch(UMAMI_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": request.headers.get("User-Agent") || "Mozilla/5.0", + }, + body: JSON.stringify({ + type: "event", + payload: { + website: WEBSITE_ID, + hostname, + ip: request.headers.get("CF-Connecting-IP") || undefined, + screen: form.get("s") || "", + language: form.get("l") || "", + title: form.get("t") || "", + url: form.get("u") || "", + referrer: form.get("r") || "", + }, + }), + }); + + return new Response(null, { status: response.ok ? 204 : 502 }); + } catch (error) { + console.error("[Analytics]", error); + return new Response(null, { status: 502 }); + } +} + +const createDefaultHistory = () => { + return new Map( + historyFixtures.map(([name, content]) => [ + name, + content + .trim() + .split("\n") + .filter((line) => line.length > 0), + ]), + ); +}; + +function resetRecordingSimulation(session) { + const recordingFile = [...session.context.historySessions.entries()].find( + ([, lines]) => !lines.some((line) => line.includes('"type":"summary"')), + ); + if (!recordingFile) { + session.recordingSimulation = null; + return; + } + + const [recordingName, recordingLines] = recordingFile; + const lastPose = [...recordingLines].reverse().find((line) => line.includes('"x":')); + const pos = lastPose ? JSON.parse(lastPose) : { x: 0, y: 0, t: 0, ts: 7244 }; + session.recordingSimulation = { + name: recordingName, + x: pos.x, + y: pos.y, + t: pos.t, + ts: pos.ts, + lastUpdate: Date.now(), + }; +} + +function updateRecordingSession(session) { + const recordingSimulation = session.recordingSimulation; + if (!recordingSimulation) return; + const elapsed = Math.floor((Date.now() - recordingSimulation.lastUpdate) / 2000); + if (elapsed <= 0) return; + recordingSimulation.lastUpdate += elapsed * 2000; + + const lines = session.context.historySessions.get(recordingSimulation.name); + if (!lines) return; + + for (let i = 0; i < elapsed; i++) { + recordingSimulation.t += (Math.random() - 0.5) * 30; + if (recordingSimulation.t < 0) recordingSimulation.t += 360; + if (recordingSimulation.t >= 360) recordingSimulation.t -= 360; + const rad = (recordingSimulation.t * Math.PI) / 180; + const step = 0.08 + Math.random() * 0.12; + recordingSimulation.x += Math.cos(rad) * step; + recordingSimulation.y -= Math.sin(rad) * step; + recordingSimulation.ts += 2.0 + Math.random() * 0.3; + if (lines.length > 1) lines.splice(1, 1); + lines.push( + `{"x":${recordingSimulation.x.toFixed(3)},"y":${recordingSimulation.y.toFixed(3)},"t":${recordingSimulation.t.toFixed(1)},"ts":${recordingSimulation.ts.toFixed(1)}}`, + ); + } +} + +const sessions = new Map(); + +function createSession(id) { + const session = { + id, + api: null, + context: null, + initializedScenario: null, + bootTime: Date.now(), + lastAccess: Date.now(), + recordingSimulation: null, + }; + session.context = { + state: {}, + faults: {}, + historySessions: new Map(), + rand: randomInt, + sleep, + getVersion: () => DEMO_VERSION, + getBootTime: () => session.bootTime, + reboot: () => { + session.bootTime = Date.now(); + }, + }; + session.api = createMockApi(session.context); + initScenario(session, "ok"); + return session; +} + +function pruneSessions(now) { + for (const [id, session] of sessions) { + if (now - session.lastAccess > SESSION_TTL_MS) sessions.delete(id); + } + while (sessions.size > MAX_SESSIONS) { + let oldestId = null; + let oldestAccess = Infinity; + for (const [id, session] of sessions) { + if (session.lastAccess < oldestAccess) { + oldestId = id; + oldestAccess = session.lastAccess; + } + } + if (!oldestId) break; + sessions.delete(oldestId); + } +} + +function getSession(request) { + const now = Date.now(); + pruneSessions(now); + + const cookieHeader = request.headers.get("Cookie") ?? ""; + let id = parseCookieValue(cookieHeader, SESSION_COOKIE); + let isNew = false; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + id = crypto.randomUUID(); + isNew = true; + } + + let session = sessions.get(id); + if (!session) { + session = createSession(id); + sessions.set(id, session); + isNew = true; + } + session.lastAccess = now; + return { session, isNew }; +} + +function initScenario(session, rawScenario) { + const scenario = rawScenario.trim() || "ok"; + if (scenario === session.initializedScenario) return; + const scenarioState = createScenarioState(scenario); + session.context.state = scenarioState.state; + session.context.faults = scenarioState.faults; + session.context.historySessions = createDefaultHistory(); + session.bootTime = Date.now(); + session.initializedScenario = scenario; + resetRecordingSimulation(session); +} + +function isBlockedDemoEndpoint(method, path, query) { + if (method === "DELETE") return true; + if (method !== "POST") return false; + if (path === "/api/firmware/update" || path === "/api/history/import") return true; + if (["/api/system/restart", "/api/system/format-fs", "/api/system/reset"].includes(path)) return true; + return path === "/api/power" && ["restart", "shutdown"].includes(query.action || ""); +} + +function scenarioForRequest(url, request) { + try { + return scenarioFromRequest(url.searchParams, request.headers.get("Cookie") ?? ""); + } catch { + return (url.searchParams.get("scenario") || "ok").trim() || "ok"; + } +} + +const toWorkerResponse = (response) => { + if (response === false) return err("not found", 404); + if (response.offline) return err("Device unreachable", 503); + return new Response(response.body ?? "", { + status: response.status, + headers: response.headers, + }); +}; + +async function handleApi(request, env) { + const url = new URL(request.url); + const { session, isNew } = getSession(request); + const scenario = scenarioForRequest(url, request); + initScenario(session, scenario); + updateRecordingSession(session); + + await sleep(randomInt(50, 200)); + + const demoMode = (env.DEMO_MODE ?? "true").toLowerCase() === "true"; + const query = Object.fromEntries(url.searchParams); + + if (demoMode && isBlockedDemoEndpoint(request.method, url.pathname, query)) { + const response = err("This action is disabled in demo mode", 403); + if (isNew) response.headers.append("Set-Cookie", sessionCookie(session.id)); + if (url.searchParams.has("scenario")) response.headers.append("Set-Cookie", scenarioCookie(scenario)); + return response; + } + + const contentLength = Number(request.headers.get("Content-Length") || "0"); + if (contentLength > MAX_BODY_BYTES) { + const response = err("Request body too large", 413); + if (isNew) response.headers.append("Set-Cookie", sessionCookie(session.id)); + if (url.searchParams.has("scenario")) response.headers.append("Set-Cookie", scenarioCookie(scenario)); + return response; + } + + let cachedBytes = null; + const bytes = async () => { + cachedBytes ??= await readBodyWithLimit(request); + return cachedBytes; + }; + + let response; + try { + response = toWorkerResponse( + await session.api.handle({ + method: request.method, + path: url.pathname, + query, + bytes, + text: async () => new TextDecoder().decode(await bytes()), + }), + ); + } catch (error) { + response = error.message === "Request body too large" ? err(error.message, 413) : err("Internal error", 500); + } + if (isNew) response.headers.append("Set-Cookie", sessionCookie(session.id)); + if (url.searchParams.has("scenario")) response.headers.append("Set-Cookie", scenarioCookie(scenario)); + return response; +} + +async function serveAsset(request, env) { + const url = new URL(request.url); + const scenario = url.searchParams.get("scenario"); + const rawAssetResponse = await env.ASSETS.fetch(request); + const assetResponse = new Response(rawAssetResponse.body, rawAssetResponse); + if (scenario) assetResponse.headers.set("Set-Cookie", scenarioCookie(scenario)); + if (assetResponse.status !== 404) return assetResponse; + + const indexRequest = new Request(new URL("/index.html", url).toString(), request); + const rawIndexResponse = await env.ASSETS.fetch(indexRequest); + const indexResponse = new Response(rawIndexResponse.body, rawIndexResponse); + if (scenario) indexResponse.headers.set("Set-Cookie", scenarioCookie(scenario)); + if (indexResponse.status !== 404) return indexResponse; + return assetResponse; +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + if (request.method === "POST" && url.pathname === "/api/collect") { + return withSecurityHeaders(await handleCollect(request, env)); + } + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/repos/")) { + return withSecurityHeaders(await handleApi(request, env)); + } + return withSecurityHeaders(await serveAsset(request, env)); + }, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 447e2c6..10f091a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,13 +8,17 @@ "name": "neato-web", "version": "0.0.1", "dependencies": { - "preact": "10.29.0" + "preact": "10.29.1" }, "devDependencies": { - "@biomejs/biome": "2.4.7", - "@preact/preset-vite": "2.10.3", - "typescript": "5.9.3", - "vite": "7.3.1" + "@biomejs/biome": "2.4.15", + "@preact/preset-vite": "2.10.5", + "jscpd": "4.2.2", + "openapi-typescript": "7.13.0", + "typescript": "6.0.3", + "vite": "8.0.13", + "wrangler": "4.92.0", + "yaml": "2.9.0" } }, "node_modules/@babel/code-frame": { @@ -333,9 +337,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.7.tgz", - "integrity": "sha512-vXrgcmNGZ4lpdwZSpMf1hWw1aWS6B+SyeSYKTLrNsiUsAdSRN0J4d/7mF3ogJFbIwFFSOL3wT92Zzxia/d5/ng==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -349,20 +353,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.7", - "@biomejs/cli-darwin-x64": "2.4.7", - "@biomejs/cli-linux-arm64": "2.4.7", - "@biomejs/cli-linux-arm64-musl": "2.4.7", - "@biomejs/cli-linux-x64": "2.4.7", - "@biomejs/cli-linux-x64-musl": "2.4.7", - "@biomejs/cli-win32-arm64": "2.4.7", - "@biomejs/cli-win32-x64": "2.4.7" + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.7.tgz", - "integrity": "sha512-Oo0cF5mHzmvDmTXw8XSjhCia8K6YrZnk7aCS54+/HxyMdZMruMO3nfpDsrlar/EQWe41r1qrwKiCa2QDYHDzWA==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", "cpu": [ "arm64" ], @@ -377,9 +381,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.7.tgz", - "integrity": "sha512-I+cOG3sd/7HdFtvDSnF9QQPrWguUH7zrkIMMykM3PtfWU9soTcS2yRb9Myq6MHmzbeCT08D1UmY+BaiMl5CcoQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", "cpu": [ "x64" ], @@ -394,13 +398,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.7.tgz", - "integrity": "sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -411,13 +418,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.7.tgz", - "integrity": "sha512-I2NvM9KPb09jWml93O2/5WMfNR7Lee5Latag1JThDRMURVhPX74p9UDnyTw3Ae6cE1DgXfw7sqQgX7rkvpc0vw==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -428,13 +438,16 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.7.tgz", - "integrity": "sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -445,13 +458,16 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.7.tgz", - "integrity": "sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -462,9 +478,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.7.tgz", - "integrity": "sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", "cpu": [ "arm64" ], @@ -479,9 +495,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.7", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.7.tgz", - "integrity": "sha512-qEpGjSkPC3qX4ycbMUthXvi9CkRq7kZpkqMY1OyhmYlYLnANnooDQ7hDerM8+0NJ+DZKVnsIc07h30XOpt7LtQ==", + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", "cpu": [ "x64" ], @@ -495,10 +511,190 @@ "node": ">=14.21.3" } }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260515.1.tgz", + "integrity": "sha512-Wtw44el2pNbzixvTkWdfeBDTrQwQbJRz7/JUvPKV27I0pQWXbhNJPpM8cstq/pbrU5AGcA/HjFH6yPMRTIRKig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260515.1.tgz", + "integrity": "sha512-X8EqkZej6FfmhF9AVAQ3FhyQRr9acS4RcDunMU2YiuxKHF1IU8zzH3vY30/POaG+rUu9vGDp/VgUl49VPenHJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260515.1.tgz", + "integrity": "sha512-CDC89QxQ7Y7t7RG1Jd9vj/qolE1sQRkI2OSEuV5BMJi0vW/gV4OVG6xjpdK3b1OYnSWDzF7NpvlR5Yg86q7k4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260515.1.tgz", + "integrity": "sha512-WxbW/PToYES4fvHXzsr/5qOiETQs/Z9iZ0mjSZAiEwq5cMLZemzGN0COx+uFb9OvQwzh6Pg159qPFnw3+i9FuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260515.1.tgz", + "integrity": "sha512-WmV/iv+MHjYsvkcMVzpM2B5/mf06UUkdpVhZrtMfV9graWjBGPYFvE/eab8748RPVGKh1Xe1vXofLzDSwc08lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -513,9 +709,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -530,9 +726,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -547,9 +743,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -564,9 +760,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -581,9 +777,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -598,9 +794,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -615,9 +811,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -632,9 +828,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -649,9 +845,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -666,9 +862,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -683,9 +879,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -700,9 +896,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -717,9 +913,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -734,9 +930,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -751,9 +947,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -768,9 +964,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -785,9 +981,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -802,9 +998,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -819,9 +1015,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -836,9 +1032,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -853,9 +1049,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -870,9 +1066,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -887,9 +1083,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -904,9 +1100,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -921,9 +1117,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -937,467 +1133,913 @@ "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==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "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==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "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==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "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==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@preact/preset-vite": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.3.tgz", - "integrity": "sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], "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", - "picocolors": "^1.1.1", - "vite-prerender-plugin": "^0.5.8" - }, - "peerDependencies": { - "@babel/core": "7.x", - "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "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==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "peerDependencies": { - "preact": "^10.0.0 || ^11.0.0-0" + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "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.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@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.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ - "arm" + "riscv64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" + "libc": [ + "glibc" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "android" - ] + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ - "arm64" + "s390x" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" - ] + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "darwin" - ] + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" - ] + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" + "libc": [ + "musl" ], - "dev": true, - "license": "MIT", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" - ] + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" + "libc": [ + "glibc" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ - "loong64" + "ppc64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ - "loong64" + "riscv64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ - "ppc64" + "s390x" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ - "ppc64" + "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "glibc" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ - "riscv64" + "x64" ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "Apache-2.0", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ - "s390x" + "wasm32" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ - "x64" + "ia32" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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/@jscpd/badge-reporter": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@jscpd/badge-reporter/-/badge-reporter-4.2.2.tgz", + "integrity": "sha512-03ngWGBtvdL6dRC7+Q/4FdoUozN4KBHpaJ1SjGnPaMuPoEmcGDdw7W/+HfhPWSwpzam3lfTM2yBsGhrG2g3LqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "badgen": "^3.2.3", + "colors": "^1.4.0", + "fs-extra": "^11.2.0" + } + }, + "node_modules/@jscpd/core": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@jscpd/core/-/core-4.2.2.tgz", + "integrity": "sha512-YheyoZAVIeZXyQ5mE1lZ+no8Rs8bo02PXnTfW6PN9QrPUOlRsuJ0br5F3T/H6GU8S2DUP5gUJPOp1Lz7oxN+aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/@jscpd/finder": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.2.2.tgz", + "integrity": "sha512-HGsvprcIVTjSU3x8+Ofb5mKq2gRYYZ8Cd1O00w97x8GWUub5sHLGOdvlP4Bw19FVoZzBz+1pjnF5dDvzEoj4rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/core": "4.2.2", + "@jscpd/tokenizer": "4.2.2", + "blamer": "^1.0.6", + "bytes": "^3.1.2", + "cli-table3": "^0.6.5", + "colors": "^1.4.0", + "fast-glob": "^3.3.2", + "fs-extra": "^11.2.0", + "markdown-table": "^2.0.0", + "pug": "^3.0.3" + } + }, + "node_modules/@jscpd/html-reporter": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@jscpd/html-reporter/-/html-reporter-4.2.2.tgz", + "integrity": "sha512-pw+spkYfgWNiDH9bURB0jWzGlfbpQjc8gUe3c726qzPSgMUe9QB3fBA44e9iAJP/o0jtizgUd4MZHyWRbVAw1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "colors": "1.4.0", + "fs-extra": "^11.2.0", + "pug": "^3.0.3" + } + }, + "node_modules/@jscpd/tokenizer": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.2.2.tgz", + "integrity": "sha512-haV7OWM2xzQ3I7tWgNTAOcR+kkFr0lYxkdnTOSd5Pve4QGx1Jk7kwk9gotqpXTd6AauaPnBdfHPxeZrUY21QYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/core": "4.2.2", + "spark-md5": "^3.0.2" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "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.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.12", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.12.tgz", + "integrity": "sha512-b32XWsz6enN6K4bx8xWsqUaXTJR/DnYT3lL1CzDYzIYKw243NNlz6fexmr71q/U4HrEcMoJGBvwAfcxOb8ymQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" - ] + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], @@ -1405,620 +2047,2608 @@ "license": "MIT", "optional": true, "os": [ - "win32" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" - ] + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "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/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", + "integrity": "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert-never": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.4.0.tgz", + "integrity": "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==", + "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/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/badgen": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/badgen/-/badgen-3.3.2.tgz", + "integrity": "sha512-fbQwK9norfdzbdsoPwbLIAmgBXDGEme3jeIyqPAH7o6vp9lmuLHS7uXULvOiQ6XnMLkYNG4gDjILf74hgtTAug==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/blamer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/blamer/-/blamer-1.0.7.tgz", + "integrity": "sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^4.0.0", + "which": "^2.0.2" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "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/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-regex": "^1.0.3" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "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/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==", + "dev": true, + "license": "MIT" + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jscpd": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.2.2.tgz", + "integrity": "sha512-zmoSXtG5Dsgo3QHhnsA06okqnKu3DlYBeAuD7MRnNQoNiL13UgBBh28pEEhe0tyR4yqYXk4Q3GAFoHx5cY+oDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jscpd/badge-reporter": "4.2.2", + "@jscpd/core": "4.2.2", + "@jscpd/finder": "4.2.2", + "@jscpd/html-reporter": "4.2.2", + "@jscpd/tokenizer": "4.2.2", + "colors": "^1.4.0", + "commander": "^5.0.0", + "fs-extra": "^11.2.0", + "jscpd-sarif-reporter": "4.2.2" + }, + "bin": { + "jscpd": "bin/jscpd" + } + }, + "node_modules/jscpd-sarif-reporter": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/jscpd-sarif-reporter/-/jscpd-sarif-reporter-4.2.2.tgz", + "integrity": "sha512-qpLhFZ6pVJcIjvEcUVRxnsrQuTbRY7bBnAgzXY9th0mL1uYhb1ii+SkMcBrBchH23vFfZu/9EG4lIxLCGfEpCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "colors": "^1.4.0", + "fs-extra": "^11.2.0", + "node-sarif-builder": "^3.4.0" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "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==", + "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": "MIT" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } }, - "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==", + "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", - "peerDependencies": { - "@babel/core": "^7.12.10" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" }, - "engines": { - "node": ">=6.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "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.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, - "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.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">= 0.4" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001779", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", - "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", - "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": { + "node_modules/merge-stream": { "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==", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "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==", + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, - "funding": { - "url": "https://github.com/sponsors/fb55" + "engines": { + "node": ">=8.6" } }, - "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==", + "node_modules/micromatch/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": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260515.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260515.0.tgz", + "integrity": "sha512-2j0oQWizk1Eu4Cm8tDX7Z+Nsjd0nebIj1TQcQ+Oy1QKeo0Ay9+bdn8wfLAtOj9znDCybDCUlnS1+nYvKXEdfNg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.24.8", + "workerd": "1.20260515.1", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" }, - "engines": { - "node": ">=6.0" + "bin": { + "miniflare": "bootstrap.js" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "engines": { + "node": ">=22.0.0" } }, - "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==", + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" + "brace-expansion": "^2.0.1" }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "engines": { + "node": ">=10" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "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/fb55" + "url": "https://github.com/sponsors/ai" } ], - "license": "BSD-2-Clause" + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "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": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0" + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" }, "engines": { - "node": ">= 4" + "node": ">=20" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "engines": { + "node": ">=8" } }, - "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==", + "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": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "boolbase": "^1.0.0" }, "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, - "license": "ISC" + "license": "ISC", + "dependencies": { + "wrappy": "1" + } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, "engines": { - "node": ">=0.12" + "node": ">=6" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, "bin": { - "esbuild": "bin/esbuild" + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { "node": ">=18" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" } }, - "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==", + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=12" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=4" } }, - "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==", + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "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": ">=6.9.0" + "node": "^10 || ^12 || >=14" } }, - "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==", + "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/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", "dev": true, "license": "MIT", - "bin": { - "he": "bin/he" + "dependencies": { + "asap": "~2.0.3" } }, - "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==", + "node_modules/pug": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.4.tgz", + "integrity": "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "pug-code-gen": "^3.0.4", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/pug-code-gen": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.4.tgz", + "integrity": "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" } }, - "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==", + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", "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==", + "node_modules/pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" } }, - "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==", + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" } }, - "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==", + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==", "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==", + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], + "license": "MIT" + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=0.10" } }, - "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==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "dependencies": { - "css-select": "^5.1.0", - "he": "1.2.0" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "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==", + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "boolbase": "^1.0.0" + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" + "type": "github", + "url": "https://github.com/sponsors/feross" }, { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" + "type": "patreon", + "url": "https://www.patreon.com/feross" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "consulting", + "url": "https://feross.org/support" } ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "queue-microtask": "^1.2.2" } }, - "node_modules/preact": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", - "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" + "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/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, - "license": "MIT", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/simple-code-frame": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz", @@ -2049,6 +4679,13 @@ "node": ">=0.10.0" } }, + "node_modules/spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/stack-trace": { "version": "1.0.0-pre2", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz", @@ -2059,15 +4696,79 @@ "node": ">=16" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "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.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2076,10 +4777,51 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2090,6 +4832,36 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2121,19 +4893,25 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -2149,9 +4927,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -2164,13 +4943,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { "optional": true }, - "lightningcss": { + "jiti": { + "optional": true + }, + "less": { "optional": true }, "sass": { @@ -2214,12 +4996,204 @@ "vite": "5.x || 6.x || 7.x || 8.x" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/workerd": { + "version": "1.20260515.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260515.1.tgz", + "integrity": "sha512-MjKOJLcvU45xXedQowvuiHtJTxu4WTHYQeIlF7YmjuqhiI6dImTFxWCEoRQHiskztxuVSNEmdO7/0UfDu6OMnQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260515.1", + "@cloudflare/workerd-darwin-arm64": "1.20260515.1", + "@cloudflare/workerd-linux-64": "1.20260515.1", + "@cloudflare/workerd-linux-arm64": "1.20260515.1", + "@cloudflare/workerd-windows-64": "1.20260515.1" + } + }, + "node_modules/wrangler": { + "version": "4.92.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.92.0.tgz", + "integrity": "sha512-/DKpQHPxkuZbQsO9dFW2700VTD/4DSZMHjy92fO/frNoDRi/zQsFCAd2ONCV6TGqcUoXcP3D8Bo2gj/L4M0qQQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260515.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260515.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260515.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "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/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "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/frontend/package.json b/frontend/package.json index f910777..f106820 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,21 +2,29 @@ "name": "neato-web", "private": true, "version": "0.0.1", + "type": "module", "scripts": { - "dev": "vite", - "build": "biome check && tsc --noEmit && vite build && node scripts/embed_frontend.js", + "dev": "npm run gen:types && vite", + "gen:types": "openapi-typescript --root-types --root-types-no-schema-prefix --output src/types.generated.ts api/openapi.yaml", + "build:demo": "node scripts/write_demo_build_info.js && biome check && jscpd src/ && npm run gen:types && node scripts/check_api_paths.js && tsc --noEmit && vite build --mode demo", + "build": "biome check && jscpd src/ && npm run gen:types && node scripts/check_api_paths.js && tsc --noEmit && vite build && node scripts/embed_frontend.js", "preview": "vite preview", "check": "biome check", + "dupes": "jscpd src/", "fix": "biome check --write", "fix:unsafe": "biome check --write --unsafe" }, "dependencies": { - "preact": "10.29.0" + "preact": "10.29.1" }, "devDependencies": { - "@biomejs/biome": "2.4.7", - "@preact/preset-vite": "2.10.3", - "typescript": "5.9.3", - "vite": "7.3.1" + "@biomejs/biome": "2.4.15", + "@preact/preset-vite": "2.10.5", + "jscpd": "4.2.2", + "openapi-typescript": "7.13.0", + "typescript": "6.0.3", + "vite": "8.0.13", + "wrangler": "4.92.0", + "yaml": "2.9.0" } } diff --git a/frontend/scripts/check_api_paths.js b/frontend/scripts/check_api_paths.js new file mode 100644 index 0000000..c8db940 --- /dev/null +++ b/frontend/scripts/check_api_paths.js @@ -0,0 +1,70 @@ +// Compares route paths registered in firmware/src/web_server.cpp against the +// paths declared in frontend/api/openapi.yaml. Fails the build if either side +// has paths the other doesn't, so a new route can't ship without spec updates +// (and a deleted route can't linger in the spec). +// +// Routes intentionally omitted from the spec must be listed in IGNORED_PATHS +// below. +// +// Usage: node frontend/scripts/check_api_paths.js + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, "..", ".."); +const cppPath = path.join(repoRoot, "firmware", "src", "web_server.cpp"); +const yamlPath = path.join(__dirname, "..", "api", "openapi.yaml"); + +// Routes registered in firmware but deliberately undocumented (e.g. diagnostic +// passthroughs that shouldn't be advertised as a stable API). +const IGNORED_PATHS = new Set(["/api/serial"]); + +function extractFirmwarePaths() { + const src = fs.readFileSync(cppPath, "utf8"); + const re = /(?:registerGetRoute|registerPostRoute|loggedRoute|loggedBodyRoute|server\.on)\s*\(\s*"(\/api\/[^"]+)"/g; + const paths = new Set(); + let m; + // biome-ignore lint/suspicious/noAssignInExpressions: classic regex loop + while ((m = re.exec(src))) { + if (!IGNORED_PATHS.has(m[1])) paths.add(m[1]); + } + return paths; +} + +function extractSpecPaths() { + const src = fs.readFileSync(yamlPath, "utf8"); + const doc = YAML.parse(src); + const paths = new Set(); + for (const p of Object.keys(doc.paths || {})) { + // OpenAPI paths use {param} placeholders; firmware paths don't (the + // handler resolves the trailing segment from request->url()). Compare + // by the leading static prefix. + const prefix = p.replace(/\/\{[^}]+\}.*/, ""); + paths.add(prefix); + } + return paths; +} + +const firmwarePaths = extractFirmwarePaths(); +const specPaths = extractSpecPaths(); + +const missingInSpec = [...firmwarePaths].filter((p) => !specPaths.has(p)).sort(); +const missingInFirmware = [...specPaths].filter((p) => !firmwarePaths.has(p)).sort(); + +if (missingInSpec.length || missingInFirmware.length) { + process.stderr.write("[check_api_paths] HTTP route surface drift detected:\n"); + if (missingInSpec.length) { + process.stderr.write(` registered in firmware but missing from openapi.yaml: ${missingInSpec.join(", ")}\n`); + } + if (missingInFirmware.length) { + process.stderr.write( + ` declared in openapi.yaml but not registered in firmware: ${missingInFirmware.join(", ")}\n`, + ); + } + process.exit(1); +} + +process.stdout.write(`[check_api_paths] OK - ${firmwarePaths.size} routes in sync\n`); diff --git a/frontend/scripts/embed_frontend.js b/frontend/scripts/embed_frontend.js index 5f15898..be728c0 100644 --- a/frontend/scripts/embed_frontend.js +++ b/frontend/scripts/embed_frontend.js @@ -6,10 +6,12 @@ // // Usage: node frontend/scripts/embed_frontend.js -const fs = require("node:fs"); -const path = require("node:path"); -const zlib = require("node:zlib"); +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import zlib from "node:zlib"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const distDir = path.join(__dirname, "..", "dist"); const outHeader = path.join(__dirname, "..", "..", "firmware", "src", "web_assets.h"); diff --git a/frontend/scripts/write_demo_build_info.js b/frontend/scripts/write_demo_build_info.js new file mode 100644 index 0000000..323e35b --- /dev/null +++ b/frontend/scripts/write_demo_build_info.js @@ -0,0 +1,16 @@ +import { execSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { mockVersionFromHash } from "../mock/shared-version.js"; + +function shortHash() { + const envHash = process.env.CF_PAGES_COMMIT_SHA || process.env.GITHUB_SHA || process.env.COMMIT_SHA; + if (envHash) return envHash.slice(0, 7); + + try { + return execSync("git rev-parse --short=7 HEAD", { encoding: "utf8" }).trim(); + } catch { + return "dev"; + } +} + +writeFileSync("mock/build-info.js", `export const DEMO_VERSION = "${mockVersionFromHash(shortHash())}";\n`); diff --git a/frontend/src/analytics.ts b/frontend/src/analytics.ts new file mode 100644 index 0000000..d458a65 --- /dev/null +++ b/frontend/src/analytics.ts @@ -0,0 +1,39 @@ +function currentUrl(): string { + return `${location.pathname}${location.search}${location.hash}`; +} + +function send(url: string, referrer?: string): void { + const payload = new URLSearchParams(); + payload.set("s", `${screen.width}x${screen.height}`); + payload.set("l", navigator.language); + payload.set("t", document.title); + payload.set("u", url); + payload.set("r", referrer || document.referrer); + + if (navigator.sendBeacon) { + navigator.sendBeacon("/api/collect", payload); + } else { + void fetch("/api/collect", { method: "POST", body: payload, keepalive: true }); + } +} + +export function startAnalytics(): void { + let previous = currentUrl(); + send(previous); + + const track = () => { + const next = currentUrl(); + if (next === previous) return; + send(next, previous); + previous = next; + }; + + const pushState = history.pushState.bind(history); + history.pushState = (...args) => { + pushState(...args); + track(); + }; + + window.addEventListener("hashchange", track); + window.addEventListener("popstate", track); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a74e0ea..5385710 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,5 +1,7 @@ import { parseMapData } from "./history-data"; import type { + BatteryAnalogData, + BatteryWarrantyData, ChargerData, ErrorData, FirmwareVersion, @@ -12,6 +14,9 @@ import type { StateData, SystemData, UserSettingsData, + VersionData, + WiFiScanResult, + WiFiStatus, } from "./types"; async function parseError(res: Response): Promise { @@ -24,10 +29,24 @@ async function parseError(res: Response): Promise { return `${res.status} ${res.statusText}`; } +// Thrown when an OK response body cannot be parsed as JSON. Distinct from +// network/HTTP errors so callers can render a recovery flow instead of a +// generic error banner. +export class ResponseParseError extends Error { + constructor(public url: string) { + super(`Failed to parse response from ${url}`); + this.name = "ResponseParseError"; + } +} + async function get(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(await parseError(res)); - return res.json(); + try { + return (await res.json()) as T; + } catch { + throw new ResponseParseError(url); + } } async function post(url: string): Promise { @@ -50,12 +69,6 @@ async function put(url: string, body: unknown): Promise { return res.json(); } -async function sendSerial(cmd: string): Promise { - const res = await fetch(`/api/serial?cmd=${encodeURIComponent(cmd)}`, { method: "POST" }); - if (!res.ok) throw new Error(await parseError(res)); - return res.text(); -} - async function fetchLogText(name: string): Promise { const res = await fetch(`/api/logs/${name}`); if (!res.ok) throw new Error(await parseError(res)); @@ -70,10 +83,10 @@ async function fetchSessionData(filename: string): Promise { return parseMapData(raw); } -function importSession(file: File, onProgress: (pct: number) => void): Promise { +function uploadFile(url: string, file: File, onProgress: (pct: number) => void): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.open("POST", "/api/history/import"); + xhr.open("POST", url); xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }); @@ -97,36 +110,20 @@ function importSession(file: File, onProgress: (pct: number) => void): Promise void): Promise { + return uploadFile("/api/history/import", file, onProgress); +} + function uploadFirmware(file: File, md5: string, onProgress: (pct: number) => void): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open("POST", `/api/firmware/update?hash=${md5}`); - xhr.upload.addEventListener("progress", (e) => { - if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); - }); - xhr.addEventListener("load", () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - try { - const data = JSON.parse(xhr.responseText); - reject(new Error(data.error || `${xhr.status} ${xhr.statusText}`)); - } catch { - reject(new Error(`${xhr.status} ${xhr.statusText}`)); - } - } - }); - xhr.addEventListener("error", () => reject(new Error("Network error during upload"))); - xhr.addEventListener("abort", () => reject(new Error("Upload aborted"))); - const form = new FormData(); - form.append("file", file); - xhr.send(form); - }); + return uploadFile(`/api/firmware/update?hash=${md5}`, file, onProgress); } export const api = { + getVersion: () => get("/api/version"), getState: () => get("/api/state"), getCharger: () => get("/api/charger"), + getBatteryAnalog: () => get("/api/analog"), + getBatteryWarranty: () => get("/api/warranty"), getError: () => get("/api/error"), getSystem: () => get("/api/system"), getFirmwareVersion: () => get("/api/firmware/version"), @@ -150,6 +147,7 @@ export const api = { clearErrors: () => post("/api/clear-errors"), robotRestart: () => post("/api/power?action=restart"), robotShutdown: () => post("/api/power?action=shutdown"), + newBattery: () => post("/api/battery/new"), restart: () => post("/api/system/restart"), formatFs: () => post("/api/system/format-fs"), factoryReset: () => post("/api/system/reset"), @@ -169,5 +167,10 @@ export const api = { getUserSettings: () => get("/api/user-settings"), setUserSetting: (key: string, value: string) => post(`/api/user-settings?key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`), - sendSerial: (cmd: string) => sendSerial(cmd), + + getWifiStatus: () => get("/api/wifi/status"), + scanWifi: () => get("/api/wifi/scan"), + connectWifi: (ssid: string, password: string) => + post(`/api/wifi/connect?ssid=${encodeURIComponent(ssid)}&password=${encodeURIComponent(password)}`), + disconnectWifi: () => post("/api/wifi/disconnect"), }; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 957ed80..9348222 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -4,6 +4,7 @@ import { Route, Router } from "./components/router"; import { usePolling } from "./hooks/use-polling"; import type { FirmwareVersion, ManualStatus, StateData } from "./types"; import { checkForUpdate, getAvailableUpdate, type UpdateInfo } from "./update"; +import { BatteryView } from "./views/battery"; import { DashboardView } from "./views/dashboard"; import { HistoryView } from "./views/history"; import { LogsView } from "./views/logs"; @@ -172,6 +173,9 @@ export function App() { + + + diff --git a/frontend/src/assets/icons/battery.svg b/frontend/src/assets/icons/battery.svg deleted file mode 100644 index aa42163..0000000 --- a/frontend/src/assets/icons/battery.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/frontend/src/assets/icons/globe.svg b/frontend/src/assets/icons/globe.svg new file mode 100644 index 0000000..89982b4 --- /dev/null +++ b/frontend/src/assets/icons/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/icons/rotate-left.svg b/frontend/src/assets/icons/rotate-left.svg new file mode 100644 index 0000000..4b4e327 --- /dev/null +++ b/frontend/src/assets/icons/rotate-left.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/icons/rotate-right.svg b/frontend/src/assets/icons/rotate-right.svg new file mode 100644 index 0000000..9e318e1 --- /dev/null +++ b/frontend/src/assets/icons/rotate-right.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/confirm-dialog.tsx b/frontend/src/components/confirm-dialog.tsx index ffd189f..fba13c9 100644 --- a/frontend/src/components/confirm-dialog.tsx +++ b/frontend/src/components/confirm-dialog.tsx @@ -3,29 +3,51 @@ import { useState } from "preact/hooks"; interface ConfirmDialogProps { message: string; confirmLabel?: string; - confirmText?: string; // When set, user must type this exact text to enable confirm + // When true (default), the confirm button is rendered as a destructive + // (red) action. Set to false for benign confirmations like Connect. + destructive?: boolean; + // When set, user must type this exact text to enable confirm. Useful for + // destructive actions (e.g. "Type RESET to confirm"). + confirmText?: string; + // Prompts the user for a free-form value (text or password). The entered + // value is passed to `onConfirm`. When `inputRequired` is true the + // confirm button is disabled until the field is non-empty. + inputType?: "text" | "password"; + inputPlaceholder?: string; + inputLabel?: string; + inputRequired?: boolean; disabled?: boolean; - onConfirm: () => void; + onConfirm: (value?: string) => void; onCancel: () => void; } export function ConfirmDialog({ message, confirmLabel = "Delete", + destructive = true, confirmText, + inputType, + inputPlaceholder, + inputLabel, + inputRequired = false, disabled = false, onConfirm, onCancel, }: ConfirmDialogProps) { const [typed, setTyped] = useState(""); + const [value, setValue] = useState(""); const textMatch = !confirmText || typed === confirmText; + const valueOk = !inputType || !inputRequired || value.length > 0; return ( // biome-ignore lint/a11y/useKeyWithClickEvents: overlay dismiss is supplementary to Cancel button