From a276f873723b33f2760b8d623aa85568191b5cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Sat, 25 Apr 2026 22:22:15 +0300 Subject: [PATCH 01/44] Show recovery panel when history list response is unparseable --- docs/user-guide.md | 48 ++++++++++++++++++------- frontend/mock/server.js | 19 ++++++++++ frontend/src/api.ts | 16 ++++++++- frontend/src/style.css | 45 +++++++++++++++++++++++ frontend/src/views/history.tsx | 65 +++++++++++++++++++++++++++++++--- 5 files changed, 175 insertions(+), 18 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index 8040331..bd95b3f 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) @@ -24,6 +23,7 @@ Everything you need to set up, configure, and troubleshoot OpenNeato. - [Enabling Logging](#enabling-logging) - [Collecting Logs](#collecting-logs) - [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 +37,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 +73,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 @@ -362,7 +355,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 diff --git a/frontend/mock/server.js b/frontend/mock/server.js index d757ddf..9243ccd 100644 --- a/frontend/mock/server.js +++ b/frontend/mock/server.js @@ -101,6 +101,7 @@ const _randf = (min, max, decimals = 2) => parseFloat((Math.random() * (max - mi // fpe — Polling fault: GET /api/error // fp — All polling faults (state + charger + error) // fhc — History corruption (inject corrupted pose lines in session data) +// fhl — History list corruption (malformed JSON in /api/history response, triggers recovery panel) // fal — All faults combined const SCENARIO = "ok"; @@ -161,6 +162,7 @@ const SCENARIOS = { fpe: { faults: { pollError: true } }, fp: { faults: { pollState: true, pollCharger: true, pollError: true } }, fhc: { faults: { historyCorrupt: true } }, + fhl: { faults: { historyListCorrupt: true } }, fal: { faults: { actions: true, @@ -171,6 +173,7 @@ const SCENARIOS = { pollCharger: true, pollError: true, historyCorrupt: true, + historyListCorrupt: true, }, }, }; @@ -898,6 +901,22 @@ const handleRequest = async (req, res) => { summary, }; }); + // Mimic the firmware bug from issue #90: one session's summary field + // contains a truncated pose snapshot concatenated with the real + // summary, which makes the entire response unparseable. Triggers the + // recovery panel in the frontend. + 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)}`; + const body = JSON.stringify(list).replace(JSON.stringify(target.summary ?? null), corruptedSummary); + res.writeHead(200, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); + return; + } return jsonResponse(res, list); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index a74e0ea..25cd70a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -24,10 +24,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 { diff --git a/frontend/src/style.css b/frontend/src/style.css index a285f24..39c39f8 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1883,6 +1883,51 @@ body { padding: 48px 0; } +.history-recovery { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + padding: 20px; + margin-top: 12px; +} + +.history-recovery-title { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + color: var(--red); +} + +.history-recovery-msg { + margin: 0 0 16px; + font-size: 14px; + line-height: 1.5; + color: var(--text-muted); +} + +.history-recovery-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.history-recovery-link { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-raised); + color: var(--text); + font-size: 12px; + font-weight: 600; + text-decoration: none; + -webkit-tap-highlight-color: transparent; +} + +.history-recovery-link:active { + opacity: 0.7; +} + .history-session-row { display: flex; align-items: center; diff --git a/frontend/src/views/history.tsx b/frontend/src/views/history.tsx index 4a6e3f9..9294af9 100644 --- a/frontend/src/views/history.tsx +++ b/frontend/src/views/history.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; -import { api } from "../api"; +import { api, ResponseParseError } from "../api"; import backSvg from "../assets/icons/back.svg?raw"; +import { ConfirmDialog } from "../components/confirm-dialog"; import { ErrorBannerStack, useErrorStack } from "../components/error-banner"; import { Icon } from "../components/icon"; import { useNavigate, usePath } from "../components/router"; @@ -9,6 +10,9 @@ import { normalizeError } from "../utils"; import { HistoryItemView } from "./history/item"; import { HistoryListView } from "./history/list"; +const RECOVERY_GUIDE_URL = + "https://github.com/renjfk/OpenNeato/blob/main/docs/user-guide.md#recovering-corrupted-cleaning-history"; + export function HistoryView() { const navigate = useNavigate(); const path = usePath(); @@ -18,6 +22,11 @@ export function HistoryView() { const [selectedMap, setSelectedMap] = useState(null); const [mapEmpty, setMapEmpty] = useState(false); const [deleting, setDeleting] = useState(false); + // Set when the list response is unparseable (e.g. one session's metadata + // contains malformed JSON that breaks the surrounding response). Triggers + // the recovery panel instead of the normal list view. + const [listCorrupted, setListCorrupted] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); // Derive selected filename from URL: /history = list, /history/ = detail const selectedName = path.startsWith("/history/") ? decodeURIComponent(path.slice(9)) : null; @@ -35,10 +44,15 @@ export function HistoryView() { // Load file list only (no full session data) useEffect(() => { setLoading(true); + setListCorrupted(false); api.getHistoryList() .then((fileList) => setFiles(sortByDateDesc(fileList))) .catch((e: unknown) => { - errorStack.push(normalizeError(e, "Failed to load map data")); + if (e instanceof ResponseParseError) { + setListCorrupted(true); + } else { + errorStack.push(normalizeError(e, "Failed to load map data")); + } }) .finally(() => setLoading(false)); }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -131,6 +145,7 @@ export function HistoryView() { api.deleteAllHistory() .then(() => { setFiles([]); + setListCorrupted(false); if (selectedName) navigate("/history"); }) .catch((e: unknown) => { @@ -164,7 +179,36 @@ export function HistoryView() {
{loading &&
Loading...
} - {!loading && files.length === 0 && !showDetail && ( + {!loading && listCorrupted && !showDetail && ( +
+

Cleaning history is corrupted

+

+ One of the stored sessions contains malformed data and is preventing the list from loading. + The recovery guide explains how to identify and remove the bad session(s) without losing the + rest. If you'd rather not investigate, you can wipe everything in one go. +

+
+ + Open recovery guide + + +
+
+ )} + + {!loading && !listCorrupted && files.length === 0 && !showDetail && ( )} - {!loading && files.length > 0 && !showDetail && ( + {!loading && !listCorrupted && files.length > 0 && !showDetail && ( )} + + {confirmReset && ( + { + setConfirmReset(false); + handleDeleteAll(); + }} + onCancel={() => setConfirmReset(false)} + /> + )}
); From fbdf5ad19092079e0186af9664c67453cecb0f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Sun, 26 Apr 2026 19:42:14 +0300 Subject: [PATCH 02/44] Animate loading wave that reveals the cleaning history map --- frontend/src/views/history/helpers.ts | 99 ++-- frontend/src/views/history/item.tsx | 80 ++- frontend/src/views/history/loading-wave.ts | 490 +++++++++++++++++++ frontend/src/views/history/motion-player.tsx | 15 +- 4 files changed, 631 insertions(+), 53 deletions(-) create mode 100644 frontend/src/views/history/loading-wave.ts diff --git a/frontend/src/views/history/helpers.ts b/frontend/src/views/history/helpers.ts index 1cd1ef6..a4278f8 100644 --- a/frontend/src/views/history/helpers.ts +++ b/frontend/src/views/history/helpers.ts @@ -4,10 +4,67 @@ import clockSvg from "../../assets/icons/clock.svg?raw"; import houseSvg from "../../assets/icons/house.svg?raw"; import manualSvg from "../../assets/icons/manual.svg?raw"; import spotSvg from "../../assets/icons/spot.svg?raw"; -import type { MapData, MapPathPoint, MapTransform } from "../../types"; +import type { MapBounds, MapData, MapPathPoint, MapTransform } from "../../types"; import { pad2 } from "../../utils"; const DEFAULT_TRANSFORM: MapTransform = { panX: 0, panY: 0, zoom: 1 }; +const MAP_PAD = 20; +const GRID_STEP = 0.5; + +export interface MapProjection { + minX: number; + maxX: number; + minY: number; + maxY: number; + scale: number; + toX: (wx: number) => number; + toY: (wy: number) => number; +} + +// World-to-canvas projection used by the static renderer and the loading wave. +// `pad` is in display pixels; the world is centered within the available area. +export function computeMapProjection(displayW: number, displayH: number, bounds: MapBounds): MapProjection { + const { minX, maxX, minY, maxY } = bounds; + const worldW = maxX - minX; + const worldH = maxY - minY; + const availW = displayW - MAP_PAD * 2; + const availH = displayH - MAP_PAD * 2; + const scale = Math.min(availW / worldW, availH / worldH); + const offX = MAP_PAD + (availW - worldW * scale) / 2; + const offY = MAP_PAD + (availH - worldH * scale) / 2; + return { + minX, + maxX, + minY, + maxY, + scale, + toX: (wx) => offX + (wx - minX) * scale, + toY: (wy) => offY + (maxY - wy) * scale, + }; +} + +// 0.5m background grid. Same look in both the static map and the wave reveal. +export function drawMapGrid(ctx: CanvasRenderingContext2D, proj: MapProjection, isDark: boolean): void { + ctx.strokeStyle = isDark ? "rgba(255, 255, 255, 0.04)" : "rgba(0, 0, 0, 0.06)"; + ctx.lineWidth = 1; + const { minX, maxX, minY, maxY, toX, toY } = proj; + for (let gx = Math.floor(minX / GRID_STEP) * GRID_STEP; gx <= maxX; gx += GRID_STEP) { + ctx.beginPath(); + ctx.moveTo(toX(gx), toY(minY)); + ctx.lineTo(toX(gx), toY(maxY)); + ctx.stroke(); + } + for (let gy = Math.floor(minY / GRID_STEP) * GRID_STEP; gy <= maxY; gy += GRID_STEP) { + ctx.beginPath(); + ctx.moveTo(toX(minX), toY(gy)); + ctx.lineTo(toX(maxX), toY(gy)); + ctx.stroke(); + } +} + +export function isDarkSurface(canvas: HTMLCanvasElement): boolean { + return getComputedStyle(canvas).getPropertyValue("--surface").trim().startsWith("#1"); +} export function formatDuration(secs: number): string { if (secs < 60) return `${secs}s`; @@ -114,48 +171,16 @@ export function renderMap( ctx.translate(panX, panY); ctx.scale(zoom, zoom); - const { minX, maxX, minY, maxY } = map.bounds; - const worldW = maxX - minX; - const worldH = maxY - minY; - - const pad = 20; - const availW = displayW - pad * 2; - const availH = displayH - pad * 2; - const scale = Math.min(availW / worldW, availH / worldH); - - // Center the map within the canvas - const renderedW = worldW * scale; - const renderedH = worldH * scale; - const offX = pad + (availW - renderedW) / 2; - const offY = pad + (availH - renderedH) / 2; - - const toX = (x: number) => offX + (x - minX) * scale; - const toY = (y: number) => offY + (maxY - y) * scale; - - const isDark = getComputedStyle(canvas).getPropertyValue("--surface").trim().startsWith("#1"); + const proj = computeMapProjection(displayW, displayH, map.bounds); + const { scale, toX, toY } = proj; + const isDark = isDarkSurface(canvas); // Background ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--surface").trim() || "#1a1a1c"; ctx.fillRect(0, 0, displayW, displayH); // Grid lines - draw first so coverage/path render on top - ctx.strokeStyle = isDark ? "rgba(255, 255, 255, 0.04)" : "rgba(0, 0, 0, 0.06)"; - ctx.lineWidth = 1; - const gridStep = 0.5; - const gridMinX = Math.floor(minX / gridStep) * gridStep; - const gridMinY = Math.floor(minY / gridStep) * gridStep; - for (let gx = gridMinX; gx <= maxX; gx += gridStep) { - ctx.beginPath(); - ctx.moveTo(toX(gx), toY(minY)); - ctx.lineTo(toX(gx), toY(maxY)); - ctx.stroke(); - } - for (let gy = gridMinY; gy <= maxY; gy += gridStep) { - ctx.beginPath(); - ctx.moveTo(toX(minX), toY(gy)); - ctx.lineTo(toX(maxX), toY(gy)); - ctx.stroke(); - } + drawMapGrid(ctx, proj, isDark); // Coverage cells — filtered by timestamp during playback const cellPx = map.cellSize * scale; diff --git a/frontend/src/views/history/item.tsx b/frontend/src/views/history/item.tsx index a9122fb..95f2c08 100644 --- a/frontend/src/views/history/item.tsx +++ b/frontend/src/views/history/item.tsx @@ -4,6 +4,7 @@ import { Icon } from "../../components/icon"; import { useMapGestures } from "../../hooks/use-map-gestures"; import type { HistoryFileInfo, MapData } from "../../types"; import { formatDuration, renderMap } from "./helpers"; +import { Wave } from "./loading-wave"; import { MotionPlayer } from "./motion-player"; interface HistoryItemViewProps { @@ -24,30 +25,77 @@ export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemV setCanvasEl(canvasRef.current); }, [map]); - // Motion playback is only available for finished sessions. While - // recording we fall back to the live static render (with its pulsing - // end marker) so the existing behaviour is unchanged. + // True while the wave is on screen — covers both the loading phase + // and the brief carrier-driven reveal phase. Flips to false when the + // carrier wave's trailing edge clears the canvas, at which point the + // wave hands the canvas back to the motion player / static render. + const [revealing, setRevealing] = useState(true); + + // Single Wave instance lives across the loading -> revealing + // transition so in-flight idle pulses carry over into the reveal + // phase rather than being torn down and replaced. We create it once + // on mount; a separate effect calls startReveal() when map arrives. + const waveRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const wave = new Wave({ canvas }); + waveRef.current = wave; + let canceled = false; + wave.done.then(() => { + if (!canceled) setRevealing(false); + }); + return () => { + canceled = true; + wave.cancel(); + waveRef.current = null; + }; + }, []); + + // Kick the reveal phase the first time map data arrives. The wave + // keeps its existing in-flight idle pulses; the carrier joins them + // as one extra pulse instead of replacing the rhythm. Subsequent + // map updates (e.g. recording-session polling) are ignored — the + // reveal animation runs once. + const revealStartedRef = useRef(false); + useEffect(() => { + if (revealStartedRef.current) return; + if (!map || map.path.length === 0) return; + const wave = waveRef.current; + if (!wave) return; + wave.startReveal(map, transform); + revealStartedRef.current = true; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + // Motion playback mounts as soon as a finished session's data is + // present. While `revealing` is true its canvas effects are + // suspended via the `canvasSuspended` prop — controls render and + // are interactive, but it doesn't fight the wave for the canvas. const showPlayer = !recording && map !== null && map.path.length > 0; - // When the player is visible it owns all canvas draws. Otherwise we - // render the static map here so recording sessions and loading states - // keep working exactly as before. + // Static render fallback — only when the player is not present + // (recording sessions). The player handles its own canvas draws when + // it owns them. We also skip while `revealing` so the wave keeps the + // canvas to itself. useEffect(() => { if (showPlayer) return; + if (revealing) return; if (map && canvasRef.current) { renderMap(canvasRef.current, map, recording, transform); } - }, [map, recording, transform, showPlayer]); + }, [map, recording, transform, showPlayer, revealing]); useEffect(() => { if (showPlayer) return; + if (revealing) return; if (!map) return; const handleResize = () => { if (map && canvasRef.current) renderMap(canvasRef.current, map, recording, transform); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); - }, [map, recording, transform, showPlayer]); + }, [map, recording, transform, showPlayer, revealing]); // Prefer list metadata summary (available immediately), fall back to // the summary parsed from the full JSONL data (available after fetch) @@ -86,15 +134,21 @@ export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemV )} - {/* Map canvas */} + {/* Map canvas. The wave owns this canvas while `revealing`; + afterwards the motion player or the static-render effect + takes over. The empty-data message replaces it only when + we know the session has no usable map. */}
- {!map && !mapEmpty &&
Loading map...
} {mapEmpty &&
Not enough data to display map
} - +
- {/* Motion player (finished sessions only) */} - {showPlayer && map && } + {/* Motion player mounts immediately when data arrives so its + controls render right away. Its canvas effects are + suspended via `canvasSuspended` until the wave resolves. */} + {showPlayer && map && ( + + )} {/* Legend */} {map && ( diff --git a/frontend/src/views/history/loading-wave.ts b/frontend/src/views/history/loading-wave.ts new file mode 100644 index 0000000..ecbf392 --- /dev/null +++ b/frontend/src/views/history/loading-wave.ts @@ -0,0 +1,490 @@ +// Loading + reveal wave animation for the cleaning history map. +// +// Two phases share one rAF loop and one pulse pool so the loading -> +// revealing transition has no visual cut: +// 1. LOADING: small noisy pulses ripple outward from near canvas center +// and fade out. Repeats until startReveal() or cancel(). +// 2. REVEAL: startReveal() spawns a "carrier" pulse at center. It looks +// like a loading pulse at birth but lives long enough to traverse +// the whole canvas and gradually fattens as it travels. Coverage +// cells, the path, and start/end markers paint in its wake. +// +// The wave owns the canvas while running. The host UI keeps its own +// canvas effects suspended until `done` resolves, then takes over to +// show the final state. + +import type { MapData, MapTransform } from "../../types"; +import { computeMapProjection, drawMapGrid, isDarkSurface, type MapProjection, renderMap } from "./helpers"; + +const CELL_SIZE_M = 0.05; // matches CELL_SIZE_M in history-data.ts + +// Tuning — chosen empirically in /tmp/openneato-loading-demo.html. +const PULSE_PERIOD_S = 2.2; +const PULSE_SPAWN_S = 0.9; +const PULSE_DRIFT = 0.4; // 0..1, how far loading pulses spawn from center +const REVEAL_RING_MULT = 3; // carrier's final ringW = ringWidthPx * this +const REVEAL_DUR_MS = 1600; +const NOISE_AMP = 0.55; // 0..1, fraction of ringW used for wobble +const REVEAL_FADE_RING_MULT = 1.5; // wake-fade and carrier overshoot in units of ringW +const SUPPRESS_FADE_RING_MULT = 3; // gentle ramp-out for idle pulses behind the carrier + +// Visual sizes scale with canvas dimension so phones get a less chunky +// wave. Desktop (>= ~600px square) lands on the original 40 / 12 values +// because of the upper clamps; very small canvases get clamped from +// below to keep the wave readable. +const RING_W_RATIO = 0.067; // ~6.7% of min(W, H), targets 40px on a 600px canvas +const RING_W_MIN = 22; +const RING_W_MAX = 40; +const ANIM_CELL_RATIO = 0.02; // ~2% of min(W, H), targets 12px on a 600px canvas +const ANIM_CELL_MIN = 7; +const ANIM_CELL_MAX = 12; + +function ringWidthPx(W: number, H: number): number { + return Math.max(RING_W_MIN, Math.min(RING_W_MAX, Math.min(W, H) * RING_W_RATIO)); +} + +function animCellPx(W: number, H: number): number { + return Math.max(ANIM_CELL_MIN, Math.min(ANIM_CELL_MAX, Math.min(W, H) * ANIM_CELL_RATIO)); +} + +type PulseRole = "loading" | "carrier"; + +interface Pulse { + bornAt: number; // seconds since wave start + ox: number; // origin in canvas pixels + oy: number; + salt: number; // unique seed for noise + duration: number; // total lifetime in seconds + role: PulseRole; + ringWStart: number; + ringWEnd: number; + maxR: number; // target front radius at end of life, pixels +} + +// Stable per-cell hash noise in [-1, 1]. No allocations, deterministic. +function hashNoise(cx: number, cy: number, salt: number): number { + let h = (cx | 0) * 374761393 + (cy | 0) * 668265263 + (salt | 0) * 2147483647; + h = (h ^ (h >>> 13)) * 1274126177; + h = h ^ (h >>> 16); + return ((h >>> 0) / 4294967295) * 2 - 1; +} + +// Smooth 2D value noise — bilinear interp of 4 corners with smoothstep. +function valueNoise2D(x: number, y: number, salt: number): number { + const xi = Math.floor(x); + const yi = Math.floor(y); + const xf = x - xi; + const yf = y - yi; + const a = hashNoise(xi, yi, salt); + const b = hashNoise(xi + 1, yi, salt); + const c = hashNoise(xi, yi + 1, salt); + const d = hashNoise(xi + 1, yi + 1, salt); + const sx = xf * xf * (3 - 2 * xf); + const sy = yf * yf * (3 - 2 * yf); + const ab = a + (b - a) * sx; + const cd = c + (d - c) * sx; + return ab + (cd - ab) * sy; +} + +function pulseFront(pl: Pulse, t: number): number { + const age = t - pl.bornAt; + if (age < 0) return -Infinity; + const localT = age / pl.duration; + if (pl.role === "carrier") { + return localT * (pl.maxR + pl.ringWEnd * REVEAL_FADE_RING_MULT); + } + return localT * pl.maxR; +} + +// Carrier ringW grows linearly across its lifetime so the wave looks +// like a loading pulse at birth and fattens as it travels. +function pulseRingW(pl: Pulse, t: number): number { + if (pl.role !== "carrier") return pl.ringWStart; + const localT = Math.max(0, Math.min(1, (t - pl.bornAt) / pl.duration)); + return pl.ringWStart + (pl.ringWEnd - pl.ringWStart) * localT; +} + +function pulseAlpha(pl: Pulse, t: number): number { + const localT = (t - pl.bornAt) / pl.duration; + if (pl.role === "carrier") { + return Math.min(1, 0.85 + 0.15 * Math.min(1, localT * 4)); + } + return Math.max(0, 1 - localT); +} + +function pulseDistancePx(pl: Pulse, px: number, py: number, ringW: number): number { + const dx = px - pl.ox; + const dy = py - pl.oy; + const dRaw = Math.sqrt(dx * dx + dy * dy); + const n = valueNoise2D(px * 0.06, py * 0.06, pl.salt); + return dRaw + n * NOISE_AMP * ringW * 0.6; +} + +// Largest distance from (ox, oy) to any canvas corner. +function farthestCorner(ox: number, oy: number, W: number, H: number): number { + return Math.max(Math.hypot(ox, oy), Math.hypot(W - ox, oy), Math.hypot(ox, H - oy), Math.hypot(W - ox, H - oy)); +} + +interface WaveOptions { + canvas: HTMLCanvasElement; +} + +export class Wave { + private canvas: HTMLCanvasElement; + private map: MapData | null = null; + private transform: MapTransform | null = null; + private carrier: Pulse | null = null; + private pulses: Pulse[] = []; + private nextSpawnAt = 0; + private saltCounter = 1; + private startTs: number; + private raf: number | null = null; + private finished = false; + private resolveDone: (() => void) | null = null; + public readonly done: Promise; + + private cachedSurface: string | null = null; + private cachedIsDark = true; + private cachedDpr = 0; + private cachedW = 0; + private cachedH = 0; + + constructor(opts: WaveOptions) { + this.canvas = opts.canvas; + this.startTs = performance.now(); + this.done = new Promise((resolve) => { + this.resolveDone = resolve; + }); + this.tick = this.tick.bind(this); + this.raf = requestAnimationFrame(this.tick); + } + + // Begin the reveal phase. Spawns a fresh "carrier" pulse at center + // and re-anchors any in-flight loading pulses onto the carrier's + // velocity so they expand at the same rate and expire together + // instead of trailing as ghosts on their own slower cadence. + startReveal(map: MapData, transform: MapTransform): void { + if (this.carrier !== null || this.finished) return; + this.map = map; + this.transform = transform; + + const W = this.canvas.clientWidth; + const H = this.canvas.clientHeight; + const ox = W / 2; + const oy = H / 2; + const ringWStart = ringWidthPx(W, H); + const ringWEnd = ringWStart * REVEAL_RING_MULT; + const t = (performance.now() - this.startTs) / 1000; + const carrier: Pulse = { + bornAt: t, + ox, + oy, + salt: this.saltCounter++, + duration: REVEAL_DUR_MS / 1000, + role: "carrier", + ringWStart, + ringWEnd, + maxR: farthestCorner(ox, oy, W, H) + ringWEnd * REVEAL_FADE_RING_MULT + 20, + }; + + // Match each loading pulse's front-velocity to the carrier's + // *rendered* velocity. The carrier's pulseFront includes an + // overshoot of `ringWEnd * REVEAL_FADE_RING_MULT` past maxR so + // its trailing edge clears the canvas; loading pulses don't get + // that bonus, so we compensate via their duration. bornAt is + // shifted to keep the current front position continuous on the + // reveal frame (no jump). + const carrierRenderedMaxR = carrier.maxR + carrier.ringWEnd * REVEAL_FADE_RING_MULT; + const carrierVelocity = carrierRenderedMaxR / carrier.duration; + for (const pl of this.pulses) { + if (pl.role !== "loading") continue; + const oldFront = pulseFront(pl, t); + const newDuration = pl.maxR / carrierVelocity; + pl.duration = newDuration; + pl.bornAt = t - (oldFront / pl.maxR) * newDuration; + } + + this.pulses.push(carrier); + this.carrier = carrier; + } + + cancel(): void { + this.finish(); + } + + // Stop the rAF loop and resolve `done`. Idempotent. + private finish(): void { + if (this.finished) return; + this.finished = true; + if (this.raf !== null) cancelAnimationFrame(this.raf); + this.raf = null; + this.resolveDone?.(); + } + + private spawnLoadingPulse(t: number): void { + const W = this.canvas.clientWidth; + const H = this.canvas.clientHeight; + const ox = W / 2 + (Math.random() - 0.5) * W * 0.5 * PULSE_DRIFT; + const oy = H / 2 + (Math.random() - 0.5) * H * 0.5 * PULSE_DRIFT; + const ringW = ringWidthPx(W, H); + this.pulses.push({ + bornAt: t, + ox, + oy, + salt: this.saltCounter++, + duration: PULSE_PERIOD_S * (0.85 + Math.random() * 0.3), + role: "loading", + ringWStart: ringW, + ringWEnd: ringW, + maxR: farthestCorner(ox, oy, W, H) + 20, + }); + } + + private tick(now: number): void { + if (this.finished) return; + const t = (now - this.startTs) / 1000; + + if (this.carrier === null) { + if (t >= this.nextSpawnAt) { + this.spawnLoadingPulse(t); + this.nextSpawnAt = t + PULSE_SPAWN_S * (0.7 + Math.random() * 0.6); + } + if (this.pulses.length === 0) this.spawnLoadingPulse(t); + } + + // Drop expired pulses + this.pulses = this.pulses.filter((pl) => t - pl.bornAt < pl.duration * 1.2); + + this.render(t); + + // Reveal completion: the carrier's trailing edge has cleared the + // farthest visible canvas pixel. Hand off to the host UI. + if (this.carrier !== null && this.map !== null && this.transform !== null) { + const front = pulseFront(this.carrier, t); + const ringW = pulseRingW(this.carrier, t); + if (front - ringW >= this.carrier.maxR) { + renderMap(this.canvas, this.map, false, this.transform); + this.finish(); + return; + } + } + + this.raf = requestAnimationFrame(this.tick); + } + + private ensureCanvasSized(): { ctx: CanvasRenderingContext2D; W: number; H: number } | null { + const ctx = this.canvas.getContext("2d"); + if (!ctx) return null; + const dpr = window.devicePixelRatio || 1; + const W = this.canvas.clientWidth; + const H = this.canvas.clientHeight; + if (W !== this.cachedW || H !== this.cachedH || dpr !== this.cachedDpr) { + this.canvas.width = W * dpr; + this.canvas.height = H * dpr; + this.cachedDpr = dpr; + this.cachedW = W; + this.cachedH = H; + this.cachedSurface = null; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (this.cachedSurface === null) { + this.cachedSurface = getComputedStyle(this.canvas).getPropertyValue("--surface").trim(); + this.cachedIsDark = isDarkSurface(this.canvas); + } + return { ctx, W, H }; + } + + private render(t: number): void { + const sized = this.ensureCanvasSized(); + if (!sized) return; + const { ctx, W: displayW, H: displayH } = sized; + const isDark = this.cachedIsDark; + + ctx.fillStyle = this.cachedSurface || "#1a1a1c"; + ctx.fillRect(0, 0, displayW, displayH); + + // Reveal phase paints the map beneath the wave foreground. + // Loading phase has no map data, so the wave runs on the bare + // background. + const proj = + this.carrier && this.map?.bounds ? computeMapProjection(displayW, displayH, this.map.bounds) : null; + + if (proj) { + const tf = this.transform ?? { panX: 0, panY: 0, zoom: 1 }; + ctx.translate(tf.panX, tf.panY); + ctx.scale(tf.zoom, tf.zoom); + drawMapGrid(ctx, proj, isDark); + this.drawRevealCoverage(ctx, t, proj, isDark); + this.drawRevealPath(ctx, t, proj, isDark); + } + + this.drawWaveCells(ctx, t, displayW, displayH, isDark); + + if (proj) this.drawRevealMarkers(ctx, t, proj); + } + + // Paint real coverage cells at native 5cm resolution behind the + // carrier wave front. Opacity ramps from 0 at the front edge to full + // settled coverage opacity within ~1.5 ringW behind the front. + private drawRevealCoverage(ctx: CanvasRenderingContext2D, t: number, proj: MapProjection, isDark: boolean): void { + const carrier = this.carrier; + const map = this.map; + if (!carrier || !map) return; + const ringW = pulseRingW(carrier, t); + const front = pulseFront(carrier, t); + const cellPx = CELL_SIZE_M * proj.scale; + const settledA = isDark ? 0.15 : 0.22; + const baseRgb = isDark ? "52, 199, 89" : "22, 130, 50"; + const fadeWidth = ringW * REVEAL_FADE_RING_MULT; + + const buckets: number[][] = Array.from({ length: 10 }, () => []); + + for (const cell of map.coverage) { + const [cx, cy] = cell; + const px = proj.toX(cx * CELL_SIZE_M); + const py = proj.toY(cy * CELL_SIZE_M); + const edge = front - pulseDistancePx(carrier, px, py, ringW); + if (edge <= 0) continue; + const settle = Math.min(1, edge / fadeWidth); + const bIdx = Math.min(9, (settle * 10) | 0); + buckets[bIdx].push(px - cellPx / 2, py - cellPx / 2); + } + + for (let i = 0; i < 10; i++) { + const arr = buckets[i]; + if (arr.length === 0) continue; + ctx.fillStyle = `rgba(${baseRgb}, ${(settledA * (i + 1)) / 10})`; + for (let j = 0; j < arr.length; j += 2) { + ctx.fillRect(arr[j], arr[j + 1], cellPx, cellPx); + } + } + } + + private drawRevealPath(ctx: CanvasRenderingContext2D, t: number, proj: MapProjection, isDark: boolean): void { + const carrier = this.carrier; + const map = this.map; + if (!carrier || !map || map.path.length < 2) return; + const ringW = pulseRingW(carrier, t); + const front = pulseFront(carrier, t); + + ctx.strokeStyle = isDark ? "rgba(249, 235, 178, 0.6)" : "rgba(180, 140, 40, 0.5)"; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.beginPath(); + let drawing = false; + for (const pt of map.path) { + const ppx = proj.toX(pt.x); + const ppy = proj.toY(pt.y); + const reached = front - pulseDistancePx(carrier, ppx, ppy, ringW) > 0; + if (reached) { + if (drawing) ctx.lineTo(ppx, ppy); + else { + ctx.moveTo(ppx, ppy); + drawing = true; + } + } else { + drawing = false; + } + } + ctx.stroke(); + } + + private drawRevealMarkers(ctx: CanvasRenderingContext2D, t: number, proj: MapProjection): void { + const carrier = this.carrier; + const map = this.map; + if (!carrier || !map || map.path.length === 0) return; + const ringW = pulseRingW(carrier, t); + const front = pulseFront(carrier, t); + const fadeWidth = ringW * REVEAL_FADE_RING_MULT; + + const drawDot = (worldX: number, worldY: number, r: number, g: number, b: number) => { + const px = proj.toX(worldX); + const py = proj.toY(worldY); + const edge = front - pulseDistancePx(carrier, px, py, ringW); + if (edge < 0) return; + const a = Math.min(1, edge / fadeWidth); + ctx.beginPath(); + ctx.arc(px, py, 5, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${0.9 * a})`; + ctx.fill(); + }; + + const start = map.path[0]; + drawDot(start.x, start.y, 52, 199, 89); + if (map.path.length > 1) { + const end = map.path[map.path.length - 1]; + drawDot(end.x, end.y, 255, 69, 58); + } + } + + // Foreground wave cells on a fixed-pixel grid (independent of map + // scale). Loading and carrier pulses share the same brightness + // formula so the carrier reads as a continuation of the rhythm. + // Behind the carrier front, contributions are suppressed with a soft + // ramp so idle pulses don't snap-cut to invisible. + private drawWaveCells( + ctx: CanvasRenderingContext2D, + t: number, + displayW: number, + displayH: number, + isDark: boolean, + ): void { + const baseRgb = isDark ? "52, 199, 89" : "22, 130, 50"; + const cellPx = animCellPx(displayW, displayH); + const cols = Math.ceil(displayW / cellPx) + 2; + const rows = Math.ceil(displayH / cellPx) + 2; + + const carrier = this.carrier; + const carrierRingW = carrier ? pulseRingW(carrier, t) : 0; + const carrierFront = carrier ? pulseFront(carrier, t) : 0; + const suppressFadeWidth = carrierRingW * SUPPRESS_FADE_RING_MULT; + const tickSeed = (t * 4) | 0; + + const buckets: number[][] = Array.from({ length: 10 }, () => []); + + for (let cy = 0; cy < rows; cy++) { + for (let cx = 0; cx < cols; cx++) { + const px = cx * cellPx; + const py = cy * cellPx; + + let suppress = 1; + if (carrier) { + const edge = carrierFront - pulseDistancePx(carrier, px, py, carrierRingW); + if (edge > carrierRingW) { + suppress = Math.max(0, 1 - (edge - carrierRingW) / suppressFadeWidth); + } + } + if (suppress <= 0.01) continue; + + let bright = 0; + for (const pl of this.pulses) { + const ringW = pulseRingW(pl, t); + const front = pulseFront(pl, t); + if (front <= -ringW) continue; + const delta = front - pulseDistancePx(pl, px, py, ringW); + const absDelta = delta < 0 ? -delta : delta; + if (absDelta < ringW) { + const f = 1 - absDelta / ringW; + const flicker = 0.9 + 0.1 * hashNoise(cx, cy, pl.salt + tickSeed); + const b = f * pulseAlpha(pl, t) * flicker; + if (b > bright) bright = b; + } + } + bright *= suppress; + if (bright <= 0.02) continue; + + buckets[Math.min(9, (bright * 10) | 0)].push(px, py); + } + } + + for (let i = 0; i < 10; i++) { + const arr = buckets[i]; + if (arr.length === 0) continue; + ctx.fillStyle = `rgba(${baseRgb}, ${(0.85 * (i + 1)) / 10})`; + for (let j = 0; j < arr.length; j += 2) { + ctx.fillRect(arr[j], arr[j + 1], cellPx, cellPx); + } + } + } +} diff --git a/frontend/src/views/history/motion-player.tsx b/frontend/src/views/history/motion-player.tsx index d9b2bcf..ccc7e30 100644 --- a/frontend/src/views/history/motion-player.tsx +++ b/frontend/src/views/history/motion-player.tsx @@ -18,9 +18,13 @@ interface MotionPlayerProps { // Seconds of session time played per real second. 1x replays in real time, // higher values speed the playback up proportionally. speed?: number; + // When true, the player's controls render as normal but it does NOT + // touch the canvas. Used by the host UI to keep play/restart/scrubber + // visible while the loading wave still owns the canvas drawing surface. + canvasSuspended?: boolean; } -export function MotionPlayer({ canvas, map, transform, speed = 8 }: MotionPlayerProps) { +export function MotionPlayer({ canvas, map, transform, speed = 8, canvasSuspended }: MotionPlayerProps) { const duration = sessionDuration(map); const [playing, setPlaying] = useState(false); // Start at the end of the timeline so a freshly opened session shows the @@ -40,20 +44,25 @@ export function MotionPlayer({ canvas, map, transform, speed = 8 }: MotionPlayer // Re-render canvas whenever the time, transform, or map changes. This // runs on every animation frame tick via the setCurrentTime update. + // While `canvasSuspended` is true the wave owns the canvas, so we + // skip painting; once the host clears the suspension this effect + // re-runs and lays down the current playhead state. useEffect(() => { + if (canvasSuspended) return; if (!canvas || !map) return; renderMap(canvas, map, false, transform, currentTime); - }, [canvas, map, transform, currentTime]); + }, [canvas, map, transform, currentTime, canvasSuspended]); // Re-render on resize — canvas backing store gets invalidated. useEffect(() => { + if (canvasSuspended) return; if (!canvas) return; const onResize = () => { renderMap(canvas, map, false, transform, timeRef.current); }; window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); - }, [canvas, map, transform]); + }, [canvas, map, transform, canvasSuspended]); // Reset to the completed map whenever the session switches. The scrubber // anchors at `duration` so the user sees the full session at rest. From 942b52323e8da92eeaa97751796a192c85b9f8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Sun, 26 Apr 2026 23:33:11 +0300 Subject: [PATCH 03/44] Update all dependencies to latest versions --- firmware/src/data_logger.cpp | 21 +- flash/go.mod | 8 +- flash/go.sum | 12 +- frontend/biome.json | 2 +- frontend/mock/server.js | 15 +- frontend/package-lock.json | 1437 ++++++++++++---------------- frontend/package.json | 11 +- frontend/scripts/embed_frontend.js | 8 +- frontend/src/svg.d.ts | 3 + frontend/vite.config.ts | 5 +- platformio.ini | 2 +- 11 files changed, 653 insertions(+), 871 deletions(-) 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/flash/go.mod b/flash/go.mod index fbb3bc3..d807f71 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.42.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.43.0 // indirect ) diff --git a/flash/go.sum b/flash/go.sum index 5acd17d..d483e31 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= 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/biome.json b/frontend/biome.json index 928bf20..67e4982 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", "files": { "includes": [ diff --git a/frontend/mock/server.js b/frontend/mock/server.js index 9243ccd..f2c57d0 100644 --- a/frontend/mock/server.js +++ b/frontend/mock/server.js @@ -3,10 +3,13 @@ // Runs as a Vite plugin — hooks into Vite's dev server middleware // To test different scenarios, edit the `state` object directly and reload -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"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); // --- Helpers --- @@ -936,7 +939,7 @@ const handleRequest = async (req, res) => { 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")) { + if (!nameMatch?.[1].endsWith(".jsonl")) { return sendError(res, "Invalid file: expected a .jsonl session file", 400); } const filename = nameMatch[1]; @@ -1201,4 +1204,4 @@ function mockApiPlugin() { }; } -module.exports = { mockApiPlugin }; +export { mockApiPlugin }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c65caf3..ce0a779 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,13 +8,13 @@ "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.13", + "@preact/preset-vite": "2.10.5", + "typescript": "6.0.3", + "vite": "8.0.10" } }, "node_modules/@babel/code-frame": { @@ -333,9 +333,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.13", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", + "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -349,20 +349,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.13", + "@biomejs/cli-darwin-x64": "2.4.13", + "@biomejs/cli-linux-arm64": "2.4.13", + "@biomejs/cli-linux-arm64-musl": "2.4.13", + "@biomejs/cli-linux-x64": "2.4.13", + "@biomejs/cli-linux-x64-musl": "2.4.13", + "@biomejs/cli-win32-arm64": "2.4.13", + "@biomejs/cli-win32-x64": "2.4.13" } }, "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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", + "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", "cpu": [ "arm64" ], @@ -377,9 +377,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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", + "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", "cpu": [ "x64" ], @@ -394,9 +394,9 @@ } }, "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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", + "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", "cpu": [ "arm64" ], @@ -414,9 +414,9 @@ } }, "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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", + "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", "cpu": [ "arm64" ], @@ -434,9 +434,9 @@ } }, "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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", + "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", "cpu": [ "x64" ], @@ -454,9 +454,9 @@ } }, "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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", + "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", "cpu": [ "x64" ], @@ -474,9 +474,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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", + "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", "cpu": [ "arm64" ], @@ -491,9 +491,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.13", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", + "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", "cpu": [ "x64" ], @@ -507,446 +507,38 @@ "node": ">=14.21.3" } }, - "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==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "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==", - "cpu": [ - "arm64" - ], + "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, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "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==", - "cpu": [ - "ia32" - ], + "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, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "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==", - "cpu": [ - "x64" - ], + "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, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -999,10 +591,39 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "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==", + "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": { @@ -1012,12 +633,14 @@ "@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" + "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" + "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x" } }, "node_modules/@prefresh/babel-plugin": { @@ -1089,47 +712,10 @@ "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==", - "cpu": [ - "arm" - ], - "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==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -1138,12 +724,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -1152,12 +741,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -1166,26 +758,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "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==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -1194,46 +775,32 @@ "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" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -1245,12 +812,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], @@ -1262,84 +832,19 @@ "optional": true, "os": [ "linux" - ] - }, - "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==", - "cpu": [ - "loong64" ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "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==", - "cpu": [ - "riscv64" - ], - "dev": true, "libc": [ "glibc" ], @@ -1347,29 +852,15 @@ "optional": true, "os": [ "linux" - ] - }, - "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==", - "cpu": [ - "riscv64" ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], @@ -1381,12 +872,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -1398,12 +892,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -1415,40 +912,51 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" - ] + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "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==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ - "arm64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ] + "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/@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-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -1457,49 +965,68 @@ "optional": true, "os": [ "win32" - ] + ], + "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-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ] - }, - "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==", - "cpu": [ - "x64" ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "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", - "optional": true, - "os": [ - "win32" - ] + "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-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==", - "cpu": [ - "x64" - ], + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "tslib": "^2.4.0" + } }, "node_modules/@types/estree": { "version": "1.0.8", @@ -1648,6 +1175,16 @@ } } }, + "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/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1727,48 +1264,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "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" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1879,6 +1374,279 @@ "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/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": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1964,9 +1732,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "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": { @@ -1977,9 +1745,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -2006,58 +1774,47 @@ } }, "node_modules/preact": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", - "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "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" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/semver": { @@ -2111,14 +1868,14 @@ } }, "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" @@ -2127,10 +1884,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "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/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": { @@ -2173,18 +1938,17 @@ } }, "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.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "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.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -2200,9 +1964,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "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", @@ -2215,13 +1980,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -2271,6 +2039,13 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index f910777..f58f4ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,6 +2,7 @@ "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", @@ -11,12 +12,12 @@ "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.13", + "@preact/preset-vite": "2.10.5", + "typescript": "6.0.3", + "vite": "8.0.10" } } 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/src/svg.d.ts b/frontend/src/svg.d.ts index c096cc5..4e008b5 100644 --- a/frontend/src/svg.d.ts +++ b/frontend/src/svg.d.ts @@ -3,3 +3,6 @@ declare module "*.svg?raw" { const content: string; export default content; } + +// Type declaration for side-effect CSS imports +declare module "*.css"; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 75fee80..3cef2f6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,13 @@ import preact from "@preact/preset-vite"; import { defineConfig } from "vite"; -export default defineConfig(({ command }) => { +export default defineConfig(async ({ command }) => { const isDev = command === "serve"; const serverHost = "0.0.0.0"; const serverPort = 5173; + const mockApiPlugin = isDev ? (await import("./mock/server.js")).mockApiPlugin : null; return { - plugins: [preact(), ...(isDev ? [require("./mock/server.js").mockApiPlugin()] : [])], + plugins: [preact(), ...(mockApiPlugin ? [mockApiPlugin()] : [])], define: isDev ? { __GITHUB_API_BASE__: JSON.stringify(`http://${serverHost}:${serverPort}`) } : {}, server: { host: serverHost, diff --git a/platformio.ini b/platformio.ini index 2ac8289..335371f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -38,7 +38,7 @@ lib_compat_mode = strict lib_ldf_mode = chain lib_deps = ESP32Async/AsyncTCP @ 3.4.10 - ESP32Async/ESPAsyncWebServer @ 3.10.1 + ESP32Async/ESPAsyncWebServer @ 3.10.3 ; --- Build modes -------------------------------------------------------------- From b5309499a2d2d8903ebf0b68f131cabf9e2e678b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Mon, 27 Apr 2026 13:21:32 +0300 Subject: [PATCH 04/44] ci: pin flash tool macOS deployment target to 11.0 --- .goreleaser.yml | 10 ++++++++++ docs/user-guide.md | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index a4230a4..b48670d 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,6 +16,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 diff --git a/docs/user-guide.md b/docs/user-guide.md index bd95b3f..c221939 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -148,6 +148,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 From 7f341cc80ceceebff669bdddea6a53de2a28af9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Sat, 25 Apr 2026 23:34:31 +0300 Subject: [PATCH 05/44] Fix OTA rejecting valid images on original ESP32 due to chip ID enum mismatch --- firmware/src/firmware_manager.cpp | 38 +++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 7 deletions(-) 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; } From 790e4cbb139ad44f434e0ccf6fd81164e532ef61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Sat, 25 Apr 2026 23:12:26 +0300 Subject: [PATCH 06/44] feat: rotate cleaning map in history view --- frontend/src/assets/icons/rotate-left.svg | 1 + frontend/src/assets/icons/rotate-right.svg | 1 + frontend/src/hooks/use-map-gestures.ts | 45 +++++++++++---- frontend/src/style.css | 56 ++++++++++++++++++ frontend/src/views/history/helpers.ts | 20 +++++-- frontend/src/views/history/item.tsx | 60 +++++++++++++++++--- frontend/src/views/history/loading-wave.ts | 27 ++++++++- frontend/src/views/history/motion-player.tsx | 11 ++-- 8 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 frontend/src/assets/icons/rotate-left.svg create mode 100644 frontend/src/assets/icons/rotate-right.svg 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/hooks/use-map-gestures.ts b/frontend/src/hooks/use-map-gestures.ts index d630bc0..9b898e5 100644 --- a/frontend/src/hooks/use-map-gestures.ts +++ b/frontend/src/hooks/use-map-gestures.ts @@ -10,8 +10,13 @@ const DOUBLE_TAP_ZOOM = 2.5; // Manages pan, zoom, and reset gestures for a canvas element. // Returns the current transform and a reset function. -export function useMapGestures(canvasRef: { current: HTMLCanvasElement | null }): MapTransform { +// `rotation` (degrees, 0/90/180/270) keeps gesture math aligned with the +// rotated drawing transform applied in renderMap so dragging right always +// pans the visible content right regardless of orientation. +export function useMapGestures(canvasRef: { current: HTMLCanvasElement | null }, rotation = 0): MapTransform { const [transform, setTransform] = useState(DEFAULT_TRANSFORM); + const rotationRef = useRef(rotation); + rotationRef.current = rotation; // Mutable refs for gesture state to avoid re-attaching listeners const tRef = useRef(DEFAULT_TRANSFORM); @@ -67,17 +72,36 @@ export function useMapGestures(canvasRef: { current: HTMLCanvasElement | null }) [clampPan, commit], ); - // Convert client coordinates to canvas-local coordinates + // Convert client coordinates to canvas-local coordinates, in the + // map's pre-rotation space. Pan/zoom transforms are applied after the + // rotation in renderMap, so gestures must operate in that same space. const toLocal = useCallback( (clientX: number, clientY: number): { x: number; y: number } => { const canvas = canvasRef.current; if (!canvas) return { x: clientX, y: clientY }; const rect = canvas.getBoundingClientRect(); - return { x: clientX - rect.left, y: clientY - rect.top }; + const sx = clientX - rect.left; + const sy = clientY - rect.top; + const theta = (-rotationRef.current * Math.PI) / 180; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + const w = canvas.clientWidth; + const h = canvas.clientHeight; + const dx = sx - w / 2; + const dy = sy - h / 2; + return { x: w / 2 + cos * dx - sin * dy, y: h / 2 + sin * dx + cos * dy }; }, [canvasRef], ); + // Rotate a screen-space delta into the map's pre-rotation space. + const rotateDelta = useCallback((dx: number, dy: number): { dx: number; dy: number } => { + const theta = (-rotationRef.current * Math.PI) / 180; + const cos = Math.cos(theta); + const sin = Math.sin(theta); + return { dx: cos * dx - sin * dy, dy: sin * dx + cos * dy }; + }, []); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; @@ -102,9 +126,8 @@ export function useMapGestures(canvasRef: { current: HTMLCanvasElement | null }) const onPointerMove = (e: PointerEvent) => { if (!dragging.current) return; - const dx = e.clientX - dragStart.current.x; - const dy = e.clientY - dragStart.current.y; - const pan = clampPan(panStart.current.x + dx, panStart.current.y + dy, tRef.current.zoom); + const d = rotateDelta(e.clientX - dragStart.current.x, e.clientY - dragStart.current.y); + const pan = clampPan(panStart.current.x + d.dx, panStart.current.y + d.dy, tRef.current.zoom); commit({ ...tRef.current, panX: pan.panX, panY: pan.panY }); }; @@ -191,9 +214,11 @@ export function useMapGestures(canvasRef: { current: HTMLCanvasElement | null }) const pan = clampPan(nextPanX + panDx, nextPanY + panDy, newZoom); commit({ panX: pan.panX, panY: pan.panY, zoom: newZoom }); } else if (e.touches.length === 1 && dragging.current) { - const dx = e.touches[0].clientX - dragStart.current.x; - const dy = e.touches[0].clientY - dragStart.current.y; - const pan = clampPan(panStart.current.x + dx, panStart.current.y + dy, tRef.current.zoom); + const d = rotateDelta( + e.touches[0].clientX - dragStart.current.x, + e.touches[0].clientY - dragStart.current.y, + ); + const pan = clampPan(panStart.current.x + d.dx, panStart.current.y + d.dy, tRef.current.zoom); commit({ ...tRef.current, panX: pan.panX, panY: pan.panY }); } }; @@ -236,7 +261,7 @@ export function useMapGestures(canvasRef: { current: HTMLCanvasElement | null }) canvas.removeEventListener("touchmove", onTouchMove); canvas.removeEventListener("touchend", onTouchEnd); }; - }, [canvasRef, clampPan, commit, reset, toLocal, zoomAt]); + }, [canvasRef, clampPan, commit, reset, toLocal, zoomAt, rotateDelta]); // Reset transform when canvas ref changes (new map loaded) useEffect(() => { diff --git a/frontend/src/style.css b/frontend/src/style.css index 39c39f8..357a8f4 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2245,6 +2245,7 @@ body { } .history-canvas-wrap { + position: relative; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; @@ -2252,6 +2253,61 @@ body { touch-action: manipulation; } +.history-rotate-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 36px; + height: 36px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.45); + color: var(--text-dim); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0.55; + transition: + opacity 0.15s, + transform 0.1s; + -webkit-tap-highlight-color: transparent; + z-index: 1; +} + +.history-rotate-btn.left { + left: 8px; +} + +.history-rotate-btn.right { + right: 8px; +} + +.history-rotate-btn svg { + width: 26px; + height: 26px; +} + +.history-rotate-btn:hover, +.history-rotate-btn:focus-visible { + opacity: 1; + color: var(--text); +} + +.history-rotate-btn:active { + transform: translateY(-50%) scale(0.9); +} + +.light .history-rotate-btn { + background: rgba(255, 255, 255, 0.7); +} + +@media (prefers-color-scheme: light) { + .system-theme .history-rotate-btn { + background: rgba(255, 255, 255, 0.7); + } +} + .history-canvas { display: block; width: 100%; diff --git a/frontend/src/views/history/helpers.ts b/frontend/src/views/history/helpers.ts index a4278f8..9d9aaca 100644 --- a/frontend/src/views/history/helpers.ts +++ b/frontend/src/views/history/helpers.ts @@ -145,12 +145,14 @@ export function interpolatePose( // renderer draws only the portion of the session up to that timestamp and // shows the interpolated robot pose as a directional sprite — used by the // motion player. Without `currentTime` the full static map is drawn. +// `rotation` rotates the map content around the canvas center (degrees, 0/90/180/270). export function renderMap( canvas: HTMLCanvasElement, map: MapData, recording = false, tf?: MapTransform, currentTime?: number, + rotation = 0, ) { const ctx = canvas.getContext("2d"); if (!ctx || !map.bounds) return; @@ -166,6 +168,20 @@ export function renderMap( canvas.height = displayH * dpr; ctx.scale(dpr, dpr); + // Fill the unrotated background first so rotation doesn't expose the + // underlying transparent canvas at the corners. + ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--surface").trim() || "#1a1a1c"; + ctx.fillRect(0, 0, displayW, displayH); + + // Rotate the map content around the canvas center. Pan/zoom are applied + // in the rotated coordinate space so gestures stay aligned with the + // visible orientation (handled in useMapGestures). + if (rotation) { + ctx.translate(displayW / 2, displayH / 2); + ctx.rotate((rotation * Math.PI) / 180); + ctx.translate(-displayW / 2, -displayH / 2); + } + // Apply zoom + pan: zoom from top-left origin, panX/panY computed by // useMapGestures already account for the cursor-relative zoom anchor. ctx.translate(panX, panY); @@ -175,10 +191,6 @@ export function renderMap( const { scale, toX, toY } = proj; const isDark = isDarkSurface(canvas); - // Background - ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--surface").trim() || "#1a1a1c"; - ctx.fillRect(0, 0, displayW, displayH); - // Grid lines - draw first so coverage/path render on top drawMapGrid(ctx, proj, isDark); diff --git a/frontend/src/views/history/item.tsx b/frontend/src/views/history/item.tsx index 95f2c08..b6d434b 100644 --- a/frontend/src/views/history/item.tsx +++ b/frontend/src/views/history/item.tsx @@ -1,5 +1,7 @@ -import { useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import boltSvg from "../../assets/icons/bolt.svg?raw"; +import rotateLeftSvg from "../../assets/icons/rotate-left.svg?raw"; +import rotateRightSvg from "../../assets/icons/rotate-right.svg?raw"; import { Icon } from "../../components/icon"; import { useMapGestures } from "../../hooks/use-map-gestures"; import type { HistoryFileInfo, MapData } from "../../types"; @@ -14,9 +16,25 @@ interface HistoryItemViewProps { recording: boolean; } +// Persisted map rotation, in degrees. Always normalized to one of 0/90/180/270. +function loadRotation(): number { + const raw = Number(localStorage.getItem("mapRotation")); + if (!Number.isFinite(raw)) return 0; + return (((Math.round(raw / 90) * 90) % 360) + 360) % 360; +} + export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemViewProps) { const canvasRef = useRef(null); - const transform = useMapGestures(canvasRef); + const [rotation, setRotation] = useState(loadRotation); + const transform = useMapGestures(canvasRef, rotation); + + useEffect(() => { + localStorage.setItem("mapRotation", String(rotation)); + }, [rotation]); + + const rotateBy = useCallback((delta: number) => { + setRotation((r) => (((r + delta) % 360) + 360) % 360); + }, []); // Expose the live canvas element so MotionPlayer can drive the renderer // without duplicating gesture handling or the ref plumbing. @@ -63,7 +81,7 @@ export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemV if (!map || map.path.length === 0) return; const wave = waveRef.current; if (!wave) return; - wave.startReveal(map, transform); + wave.startReveal(map, transform, rotation); revealStartedRef.current = true; // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); @@ -82,20 +100,20 @@ export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemV if (showPlayer) return; if (revealing) return; if (map && canvasRef.current) { - renderMap(canvasRef.current, map, recording, transform); + renderMap(canvasRef.current, map, recording, transform, undefined, rotation); } - }, [map, recording, transform, showPlayer, revealing]); + }, [map, recording, transform, showPlayer, revealing, rotation]); useEffect(() => { if (showPlayer) return; if (revealing) return; if (!map) return; const handleResize = () => { - if (map && canvasRef.current) renderMap(canvasRef.current, map, recording, transform); + if (map && canvasRef.current) renderMap(canvasRef.current, map, recording, transform, undefined, rotation); }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); - }, [map, recording, transform, showPlayer, revealing]); + }, [map, recording, transform, showPlayer, revealing, rotation]); // Prefer list metadata summary (available immediately), fall back to // the summary parsed from the full JSONL data (available after fetch) @@ -141,13 +159,39 @@ export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemV
{mapEmpty &&
Not enough data to display map
} + {map && !mapEmpty && ( + <> + + + + )}
{/* Motion player mounts immediately when data arrives so its controls render right away. Its canvas effects are suspended via `canvasSuspended` until the wave resolves. */} {showPlayer && map && ( - + )} {/* Legend */} diff --git a/frontend/src/views/history/loading-wave.ts b/frontend/src/views/history/loading-wave.ts index ecbf392..37a16fa 100644 --- a/frontend/src/views/history/loading-wave.ts +++ b/frontend/src/views/history/loading-wave.ts @@ -133,6 +133,7 @@ export class Wave { private canvas: HTMLCanvasElement; private map: MapData | null = null; private transform: MapTransform | null = null; + private rotation = 0; private carrier: Pulse | null = null; private pulses: Pulse[] = []; private nextSpawnAt = 0; @@ -163,10 +164,11 @@ export class Wave { // and re-anchors any in-flight loading pulses onto the carrier's // velocity so they expand at the same rate and expire together // instead of trailing as ghosts on their own slower cadence. - startReveal(map: MapData, transform: MapTransform): void { + startReveal(map: MapData, transform: MapTransform, rotation: number = 0): void { if (this.carrier !== null || this.finished) return; this.map = map; this.transform = transform; + this.rotation = rotation; const W = this.canvas.clientWidth; const H = this.canvas.clientHeight; @@ -263,7 +265,7 @@ export class Wave { const front = pulseFront(this.carrier, t); const ringW = pulseRingW(this.carrier, t); if (front - ringW >= this.carrier.maxR) { - renderMap(this.canvas, this.map, false, this.transform); + renderMap(this.canvas, this.map, false, this.transform, undefined, this.rotation); this.finish(); return; } @@ -311,16 +313,35 @@ export class Wave { if (proj) { const tf = this.transform ?? { panX: 0, panY: 0, zoom: 1 }; + ctx.save(); + if (this.rotation) { + ctx.translate(displayW / 2, displayH / 2); + ctx.rotate((this.rotation * Math.PI) / 180); + ctx.translate(-displayW / 2, -displayH / 2); + } ctx.translate(tf.panX, tf.panY); ctx.scale(tf.zoom, tf.zoom); drawMapGrid(ctx, proj, isDark); this.drawRevealCoverage(ctx, t, proj, isDark); this.drawRevealPath(ctx, t, proj, isDark); + ctx.restore(); } this.drawWaveCells(ctx, t, displayW, displayH, isDark); - if (proj) this.drawRevealMarkers(ctx, t, proj); + if (proj) { + ctx.save(); + if (this.rotation) { + ctx.translate(displayW / 2, displayH / 2); + ctx.rotate((this.rotation * Math.PI) / 180); + ctx.translate(-displayW / 2, -displayH / 2); + } + const tf = this.transform ?? { panX: 0, panY: 0, zoom: 1 }; + ctx.translate(tf.panX, tf.panY); + ctx.scale(tf.zoom, tf.zoom); + this.drawRevealMarkers(ctx, t, proj); + ctx.restore(); + } } // Paint real coverage cells at native 5cm resolution behind the diff --git a/frontend/src/views/history/motion-player.tsx b/frontend/src/views/history/motion-player.tsx index ccc7e30..129faef 100644 --- a/frontend/src/views/history/motion-player.tsx +++ b/frontend/src/views/history/motion-player.tsx @@ -15,6 +15,7 @@ interface MotionPlayerProps { canvas: HTMLCanvasElement | null; map: MapData; transform: MapTransform; + rotation: number; // Seconds of session time played per real second. 1x replays in real time, // higher values speed the playback up proportionally. speed?: number; @@ -24,7 +25,7 @@ interface MotionPlayerProps { canvasSuspended?: boolean; } -export function MotionPlayer({ canvas, map, transform, speed = 8, canvasSuspended }: MotionPlayerProps) { +export function MotionPlayer({ canvas, map, transform, rotation, speed = 8, canvasSuspended }: MotionPlayerProps) { const duration = sessionDuration(map); const [playing, setPlaying] = useState(false); // Start at the end of the timeline so a freshly opened session shows the @@ -50,19 +51,19 @@ export function MotionPlayer({ canvas, map, transform, speed = 8, canvasSuspende useEffect(() => { if (canvasSuspended) return; if (!canvas || !map) return; - renderMap(canvas, map, false, transform, currentTime); - }, [canvas, map, transform, currentTime, canvasSuspended]); + renderMap(canvas, map, false, transform, currentTime, rotation); + }, [canvas, map, transform, currentTime, rotation, canvasSuspended]); // Re-render on resize — canvas backing store gets invalidated. useEffect(() => { if (canvasSuspended) return; if (!canvas) return; const onResize = () => { - renderMap(canvas, map, false, transform, timeRef.current); + renderMap(canvas, map, false, transform, timeRef.current, rotation); }; window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); - }, [canvas, map, transform, canvasSuspended]); + }, [canvas, map, transform, rotation, canvasSuspended]); // Reset to the completed map whenever the session switches. The scrubber // anchors at `duration` so the user sees the full session at rest. From fe4caddf44570b3f7ba90a83ba817472445c3caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Sun, 26 Apr 2026 23:12:52 +0300 Subject: [PATCH 07/44] feat: generate OpenAPI spec and Markdown API reference per release --- .github/workflows/release.yml | 4 +- .gitignore | 1 + .goreleaser.yml | 4 + AGENTS.md | 10 +- firmware/src/web_server.cpp | 167 +++++ frontend/package.json | 2 +- frontend/scripts/gen_api_docs.js | 1013 +++++++++++++++++++++++++++ frontend/src/types.ts | 205 +++++- frontend/src/views/history/item.tsx | 2 +- 9 files changed, 1382 insertions(+), 26 deletions(-) create mode 100644 frontend/scripts/gen_api_docs.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4cd4c0..d7c4cf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,9 @@ jobs: run: npm ci working-directory: frontend - - name: Build frontend (lint + typecheck + vite + embed) + - name: Build frontend (lint + typecheck + vite + embed + api docs) + env: + OPENNEATO_VERSION: ${{ needs.prepare.outputs.version }} run: npm run build working-directory: frontend diff --git a/.gitignore b/.gitignore index ddd21ec..d95fef0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ firmware/compile_commands.json firmware/src/web_assets.h frontend/node_modules frontend/dist +frontend/dist-docs flash/openneato-flash release/ dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml index b48670d..43dc56c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -53,12 +53,16 @@ release: extra_files: - glob: .pio/build/*-release/*-firmware.bin - glob: .pio/build/*-release/*-full.tar.gz + - glob: frontend/dist-docs/openapi.json + - glob: frontend/dist-docs/api-reference.md checksum: name_template: checksums.txt extra_files: - glob: .pio/build/*-release/*-firmware.bin - glob: .pio/build/*-release/*-full.tar.gz + - glob: frontend/dist-docs/openapi.json + - glob: frontend/dist-docs/api-reference.md changelog: sort: asc diff --git a/AGENTS.md b/AGENTS.md index d55b2be..0c7a218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ 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 -> tsc -> vite -> `embed_frontend.js` generates `web_assets.h` -> + `gen_api_docs.js` generates `frontend/dist-docs/{openapi.json, api-reference.md}` (shipped as release assets) ### Data Logging @@ -43,6 +44,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 + +When adding/changing an HTTP route, annotate it with `// @doc` directives in +`web_server.cpp` and keep TSDoc (`/** ... */`) on every field of the matching +response/body interface in `frontend/src/types.ts`. The generator (`npm run build`) +fails on drift between firmware JSON keys and TS interfaces. + ### Filesystem and Flash Wear SPIFFS (not LittleFS). Buffer writes in RAM, never write to flash in a loop. diff --git a/firmware/src/web_server.cpp b/firmware/src/web_server.cpp index a710c8e..e6535d4 100644 --- a/firmware/src/web_server.cpp +++ b/firmware/src/web_server.cpp @@ -77,32 +77,78 @@ void WebServer::begin() { void WebServer::registerApiRoutes() { // -- Sensor query endpoints ---------------------------------------------- + // @tag Sensors: Read-only telemetry from the robot + // @doc summary: Get robot identity (model, serial, firmware versions) + // @doc response: VersionData registerGetRoute("/api/version", neato, &NeatoSerial::getVersion, {}); + // @doc summary: Get battery and charger status + // @doc response: ChargerData registerGetRoute("/api/charger", neato, &NeatoSerial::getCharger, {}); + // @doc summary: Get motor telemetry (brush, vacuum, wheels, side brush, laser) + // @doc response: MotorData registerGetRoute( "/api/motors", neato, static_cast)>(&NeatoSerial::getMotors), {}); + // @doc summary: Get current UI and robot state machine values + // @doc response: StateData registerGetRoute("/api/state", neato, &NeatoSerial::getState, {}); + // @doc summary: Get current error or warning state + // @doc response: ErrorData registerGetRoute("/api/error", neato, &NeatoSerial::getErr, {}); + // @doc summary: Get latest 360-point LIDAR scan + // @doc response: LidarScan registerGetRoute("/api/lidar", neato, &NeatoSerial::getLdsScan, {}); + // @doc summary: Get robot on-board user settings (sounds, eco, wall follow, maintenance) + // @doc response: UserSettingsData registerGetRoute("/api/user-settings", neato, &NeatoSerial::getUserSettings, {}); // -- Action endpoints ---------------------------------------------------- // All parameterized actions use query strings: resource URL identifies the // command, query params carry arguments (mirrors Neato serial protocol). + // @tag Actions: Control commands sent to the robot + // @doc summary: Start, pause, resume, stop, or dock cleaning + // @doc query: action enum=house,spot,pause,stop,dock required + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/clean", neato, &NeatoSerial::clean, {"action"}); + // @doc summary: Play a robot sound effect + // @doc query: id integer 0..20 required + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/sound", neato, &NeatoSerial::playSound, {"id"}); + // @doc summary: Enter or exit test mode (required for manual control) + // @doc query: enable boolean 0..1 required + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/testmode", neato, &NeatoSerial::testMode, {"enable"}); + // @doc summary: Restart or shutdown the robot + // @doc query: action enum=restart,shutdown required + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/power", neato, &NeatoSerial::powerControl, {"action"}); + // @doc summary: Start or stop LIDAR turret rotation + // @doc query: enable boolean 0..1 required + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/lidar/rotate", neato, &NeatoSerial::setLdsRotation, {"enable"}); + // @doc summary: Set a single robot on-board user setting + // @doc query: key string required + // @doc query: value string required + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/user-settings", neato, &NeatoSerial::setUserSetting, {"key", "value"}); + // @doc summary: Clear all UI errors and warnings on the robot + // @doc response: Ok + // @doc errors: 503=robot busy, 504=robot timeout 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). + // Excluded from public API docs (diagnostics-only passthrough). + // @doc skip server.on("/api/serial", HTTP_POST, [this](AsyncWebServerRequest *request) { lastApiActivity = millis(); unsigned long startMs = lastApiActivity; @@ -141,13 +187,35 @@ void WebServer::registerApiRoutes() { void WebServer::registerManualRoutes() { // Register longer paths first — ESPAsyncWebServer matches routes by prefix, // so /api/manual would swallow /api/manual/move and /api/manual/motors. + // @tag Manual: Manual cleaning mode (joystick, motors) + + // @doc path: /api/manual/status + // @doc method: GET + // @doc summary: Get manual mode state (active flag, motors, bumpers, stalls) + // @doc response: ManualStatus loggedRoute("/api/manual/status", HTTP_GET, [this](AsyncWebServerRequest *request) { request->send(200, "application/json", manualMgr.getStatusJson()); return 200; }); + // @doc summary: Drive the wheels for a specific distance at a given speed + // @doc query: left integer required (mm, negative=backward) + // @doc query: right integer required (mm, negative=backward) + // @doc query: speed integer required (mm/s) + // @doc response: Ok + // @doc errors: 503=manual mode inactive or unsafe state, 504=robot timeout registerPostRoute("/api/manual/move", manualMgr, &ManualCleanManager::move, {"left", "right", "speed"}); + // @doc summary: Toggle main brush, vacuum, and side brush motors + // @doc query: brush boolean 0..1 required + // @doc query: vacuum boolean 0..1 required + // @doc query: sideBrush boolean 0..1 required + // @doc response: Ok + // @doc errors: 503=manual mode inactive, 504=robot timeout registerPostRoute("/api/manual/motors", manualMgr, &ManualCleanManager::setMotors, {"brush", "vacuum", "sideBrush"}); + // @doc summary: Enable or disable manual mode (also enters TestMode and starts LIDAR) + // @doc query: enable boolean 0..1 required + // @doc response: Ok + // @doc errors: 503=cannot enter manual mode, 504=robot timeout registerPostRoute("/api/manual", manualMgr, &ManualCleanManager::enable, {"enable"}); LOG("WEB", "Manual clean routes registered"); @@ -174,11 +242,23 @@ static String logListJson(const std::vector& files) { } void WebServer::registerLogRoutes() { + // @tag Logs: Diagnostic log files stored in flash + // 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. + // @doc path: /api/logs + // @doc method: GET + // @doc summary: List all log files + // @doc response: array of LogFileInfo + // @doc path: /api/logs/{filename} + // @doc method: GET + // @doc summary: Download a single log file (transparently decompressed) + // @doc param: filename string required + // @doc response: application/x-ndjson + // @doc errors: 404=log not found server.on("/api/logs", HTTP_GET, [this](AsyncWebServerRequest *request) { unsigned long startMs = millis(); String filename = request->url().substring(String("/api/logs/").length()); @@ -211,6 +291,16 @@ void WebServer::registerLogRoutes() { }); // DELETE /api/logs[/filename] — delete all logs or a specific file + // @doc path: /api/logs + // @doc method: DELETE + // @doc summary: Delete all log files + // @doc response: Ok + // @doc path: /api/logs/{filename} + // @doc method: DELETE + // @doc summary: Delete a single log file + // @doc param: filename string required + // @doc response: Ok + // @doc errors: 404=log not found loggedRoute("/api/logs", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { String filename = request->url().substring(String("/api/logs/").length()); @@ -235,13 +325,23 @@ void WebServer::registerLogRoutes() { // -- System health endpoint --------------------------------------------------- void WebServer::registerSystemRoutes() { + // @tag System: ESP32 system health and lifecycle + // GET /api/system — live system health (heap, uptime, RSSI, storage, NTP) + // @doc path: /api/system + // @doc method: GET + // @doc summary: Get live system metrics (heap, uptime, WiFi RSSI, storage, NTP, time) + // @doc response: SystemData 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 + // @doc path: /api/system/restart + // @doc method: POST + // @doc summary: Restart the ESP32 (deferred 500ms to flush HTTP response) + // @doc response: Ok loggedRoute("/api/system/restart", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { sendOk(request); sysMgr.restart(); @@ -249,6 +349,10 @@ void WebServer::registerSystemRoutes() { }); // POST /api/system/reset — factory reset (clears NVS + WiFi, then restarts) + // @doc path: /api/system/reset + // @doc method: POST + // @doc summary: Factory reset (clear NVS and WiFi credentials, then restart) + // @doc response: Ok loggedRoute("/api/system/reset", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { sendOk(request); sysMgr.factoryReset(); @@ -256,6 +360,10 @@ void WebServer::registerSystemRoutes() { }); // POST /api/system/format-fs — format filesystem (erases logs + map data, then restarts) + // @doc path: /api/system/format-fs + // @doc method: POST + // @doc summary: Format the SPIFFS filesystem (erases logs and history, then restart) + // @doc response: Ok loggedRoute("/api/system/format-fs", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { sendOk(request); sysMgr.formatFs(); @@ -268,13 +376,25 @@ void WebServer::registerSystemRoutes() { // -- Settings endpoint ------------------------------------------------------- void WebServer::registerSettingsRoutes() { + // @tag Settings: User-configurable bridge settings + // GET /api/settings — all user-configurable settings + // @doc path: /api/settings + // @doc method: GET + // @doc summary: Get all bridge settings (timezone, logging, WiFi, navigation, schedule, notifications) + // @doc response: SettingsData 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) + // @doc path: /api/settings + // @doc method: PUT + // @doc summary: Partial settings update (only fields present in body are written) + // @doc body: SettingsData (partial) + // @doc response: SettingsData + // @doc errors: 400=invalid settings loggedBodyRoute("/api/settings", HTTP_PUT, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len) -> int { String body = String(reinterpret_cast(data), len); @@ -296,6 +416,12 @@ void WebServer::registerSettingsRoutes() { }); // POST /api/notifications/test?topic= — send a test notification + // @doc path: /api/notifications/test + // @doc method: POST + // @doc summary: Send a test push notification to the given ntfy.sh topic + // @doc query: topic string required + // @doc response: Ok + // @doc errors: 400=missing or empty topic loggedRoute("/api/notifications/test", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { if (!request->hasParam("topic")) { sendError(request, 400, "missing topic"); @@ -317,7 +443,13 @@ void WebServer::registerSettingsRoutes() { // -- Firmware endpoints ------------------------------------------------------- void WebServer::registerFirmwareRoutes() { + // @tag Firmware: ESP32 firmware version and OTA update + // GET /api/firmware/version — current ESP32 firmware version + chip model + robot support status + // @doc path: /api/firmware/version + // @doc method: GET + // @doc summary: Get current ESP32 firmware version, chip model, robot model, and support status + // @doc response: FirmwareVersion loggedRoute("/api/firmware/version", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { std::vector fields = { {"version", fwMgr.getFirmwareVersion(), FIELD_STRING}, @@ -332,6 +464,13 @@ void WebServer::registerFirmwareRoutes() { }); // POST /api/firmware/update?hash= — single-request firmware upload + // @doc path: /api/firmware/update + // @doc method: POST + // @doc summary: Upload a new firmware image and verify against the supplied MD5 + // @doc query: hash string required (MD5 of firmware binary) + // @doc body: multipart/form-data file= + // @doc response: text/plain "OK" + // @doc errors: 400=update failed (bad hash, write error, or MD5 mismatch) server.on( "/api/firmware/update", HTTP_POST, // Response handler (called after upload completes) @@ -388,7 +527,19 @@ void WebServer::registerFirmwareRoutes() { // -- Map data endpoints ------------------------------------------------------- void WebServer::registerMapRoutes() { + // @tag History: Cleaning session history and map data + // GET /api/history[/filename] — list sessions, collection status, or download a specific file + // @doc path: /api/history + // @doc method: GET + // @doc summary: List all cleaning session files with embedded session and summary metadata + // @doc response: array of HistoryFileInfo + // @doc path: /api/history/{filename} + // @doc method: GET + // @doc summary: Download a single session file (transparently decompressed) + // @doc param: filename string required + // @doc response: application/x-ndjson + // @doc errors: 404=session not found server.on("/api/history", HTTP_GET, [this](AsyncWebServerRequest *request) { lastApiActivity = millis(); unsigned long startMs = lastApiActivity; @@ -443,6 +594,16 @@ void WebServer::registerMapRoutes() { }); // DELETE /api/history[/filename] — delete one or all sessions + // @doc path: /api/history + // @doc method: DELETE + // @doc summary: Delete all cleaning session files + // @doc response: Ok + // @doc path: /api/history/{filename} + // @doc method: DELETE + // @doc summary: Delete a single cleaning session file + // @doc param: filename string required + // @doc response: Ok + // @doc errors: 404=session not found loggedRoute("/api/history", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { String filename = request->url().substring(String("/api/history/").length()); @@ -462,6 +623,12 @@ void WebServer::registerMapRoutes() { }); // POST /api/history/import — upload a .jsonl session file, compress and store + // @doc path: /api/history/import + // @doc method: POST + // @doc summary: Upload a JSONL session file (compressed and stored on flash) + // @doc body: multipart/form-data file= + // @doc response: Ok + // @doc errors: 400=import failed (invalid file or write error) server.on( "/api/history/import", HTTP_POST, // Response handler (called after upload completes) diff --git a/frontend/package.json b/frontend/package.json index f58f4ae..d68c33e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "biome check && tsc --noEmit && vite build && node scripts/embed_frontend.js", + "build": "biome check && tsc --noEmit && vite build && node scripts/embed_frontend.js && node scripts/gen_api_docs.js", "preview": "vite preview", "check": "biome check", "fix": "biome check --write", diff --git a/frontend/scripts/gen_api_docs.js b/frontend/scripts/gen_api_docs.js new file mode 100644 index 0000000..a5c2c42 --- /dev/null +++ b/frontend/scripts/gen_api_docs.js @@ -0,0 +1,1013 @@ +// Generates an OpenAPI 3.0 spec and a human-readable Markdown reference for +// the HTTP API exposed by the firmware. +// +// Sources: +// - firmware/src/web_server.cpp (route registrations + // @doc directives) +// - frontend/src/types.ts (response/body schemas via TSDoc) +// +// Outputs: +// - frontend/dist-docs/openapi.json +// - frontend/dist-docs/api-reference.md +// +// Usage: node frontend/scripts/gen_api_docs.js + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, "..", ".."); +const cppPath = path.join(repoRoot, "firmware", "src", "web_server.cpp"); +const tsPath = path.join(repoRoot, "frontend", "src", "types.ts"); +const outDir = path.join(__dirname, "..", "dist-docs"); + +const VERSION = process.env.OPENNEATO_VERSION || readFirmwareVersion(); + +// Maps the manager method referenced in registerGetRoute(...) to the response +// schema name in types.ts. Static_cast<...> wrappers are stripped before +// lookup. +const GET_RESPONSE_TYPES = { + "&NeatoSerial::getVersion": "VersionData", + "&NeatoSerial::getCharger": "ChargerData", + "&NeatoSerial::getMotors": "MotorData", + "&NeatoSerial::getState": "StateData", + "&NeatoSerial::getErr": "ErrorData", + "&NeatoSerial::getLdsScan": "LidarScan", + "&NeatoSerial::getUserSettings": "UserSettingsData", +}; + +const TAG_DEFAULTS = { + Sensors: "Read-only telemetry from the robot", + Actions: "Control commands sent to the robot", + Manual: "Manual cleaning mode (joystick, motors)", + Logs: "Diagnostic log files stored in flash", + System: "ESP32 system health and lifecycle", + Settings: "User-configurable bridge settings", + Firmware: "ESP32 firmware version and OTA update", + History: "Cleaning session history and map data", +}; + +// -- C++ route extraction ---------------------------------------------------- + +function parseCpp() { + const src = fs.readFileSync(cppPath, "utf8"); + const lines = src.split("\n"); + + const routes = []; + let pendingDoc = null; + let currentTag = null; + + const blankEntry = () => ({ + summary: null, + manualPath: null, + manualMethod: null, + queries: [], + pathParams: [], + body: null, + responses: [], + errors: [], + tag: null, + }); + + const startDoc = () => { + if (!pendingDoc) { + pendingDoc = { + skip: false, + tag: null, + entries: [], + _currentEntry: null, + }; + } + if (!pendingDoc._currentEntry) { + pendingDoc._currentEntry = blankEntry(); + pendingDoc.entries.push(pendingDoc._currentEntry); + } + }; + + const handleDocLine = (raw) => { + const tagMatch = raw.match(/^\s*\/\/\s*@tag\s+([A-Za-z][\w-]*)\s*:\s*(.+?)\s*$/); + if (tagMatch) { + const [, name, desc] = tagMatch; + currentTag = name; + TAG_DEFAULTS[name] = desc; + return; + } + + const docMatch = raw.match(/^\s*\/\/\s*@doc\s+(.*)$/); + if (!docMatch) return; + const rest = docMatch[1].trim(); + + if (rest === "skip") { + startDoc(); + pendingDoc.skip = true; + return; + } + + const colon = rest.indexOf(":"); + if (colon < 0) { + warn(`Malformed @doc directive: ${raw.trim()}`); + return; + } + const key = rest.slice(0, colon).trim().toLowerCase(); + const value = rest.slice(colon + 1).trim(); + + startDoc(); + + // "path:" starts a new entry within the same comment block (e.g. when a + // single server.on() handler covers /api/logs and /api/logs/{filename}). + if (key === "path" && pendingDoc._currentEntry.manualPath) { + pendingDoc._currentEntry = blankEntry(); + pendingDoc.entries.push(pendingDoc._currentEntry); + } + + const entry = pendingDoc._currentEntry; + switch (key) { + case "path": + entry.manualPath = value; + break; + case "method": + entry.manualMethod = value.toUpperCase(); + break; + case "tag": + entry.tag = value; + break; + case "summary": + entry.summary = value; + break; + case "query": + entry.queries.push(parseParamSpec(value)); + break; + case "param": + entry.pathParams.push(parseParamSpec(value)); + break; + case "body": + entry.body = parseBodySpec(value); + break; + case "response": + entry.responses.push(parseResponseSpec(value)); + break; + case "errors": + entry.errors.push(...parseErrorsSpec(value)); + break; + default: + warn(`Unknown @doc key: ${key}`); + } + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (/^\s*\/\/\s*@(doc|tag)\b/.test(line)) { + handleDocLine(line); + continue; + } + + if (!pendingDoc) continue; + + if (pendingDoc.skip) { + // Consume the next registration call and forget the pending doc. + if (looksLikeRegistration(line)) pendingDoc = null; + continue; + } + + const consumed = tryConsumeRegistration(lines, i, pendingDoc, currentTag, routes); + if (consumed > 0) { + i += consumed - 1; + pendingDoc = null; + } + } + + return routes; +} + +function looksLikeRegistration(line) { + return /\b(registerGetRoute|registerPostRoute|loggedRoute|loggedBodyRoute|server\.on)\s*\(/.test(line); +} + +// Reads a possibly multi-line registration call starting at lines[i] and, if +// found, appends one or more route records to `routes`. Returns the number of +// source lines consumed (0 if no registration was found on this line). +function tryConsumeRegistration(lines, i, pendingDoc, currentTag, routes) { + if (!looksLikeRegistration(lines[i])) return 0; + + let depth = 0; + let buf = ""; + let consumed = 0; + let started = false; + for (let j = i; j < lines.length && j < i + 30; j++) { + const line = lines[j]; + for (let k = 0; k < line.length; k++) { + const c = line[k]; + if (c === "(") { + depth++; + started = true; + } else if (c === ")") { + depth--; + } + buf += c; + if (started && depth === 0) { + consumed = j - i + 1; + break; + } + } + buf += "\n"; + if (started && depth === 0) break; + consumed = j - i + 1; + } + + const helper = buf.match(/(registerGetRoute|registerPostRoute|loggedRoute|loggedBodyRoute|server\.on)\s*\(/); + if (!helper) return 0; + const helperName = helper[1]; + + const pathMatch = buf.match(/"([^"]+)"/); + const sniffedPath = pathMatch ? pathMatch[1] : null; + + const methodMatch = buf.match(/HTTP_(GET|POST|PUT|DELETE|PATCH)/); + const sniffedMethod = methodMatch ? methodMatch[1] : null; + + let methodPtr = null; + if (helperName === "registerGetRoute" || helperName === "registerPostRoute") { + const cast = buf.match(/static_cast<[^>]+>\s*\(\s*(&[A-Za-z_][\w:]*::[A-Za-z_]\w*)\s*\)/); + const direct = buf.match(/,\s*(&[A-Za-z_][\w:]*::[A-Za-z_]\w*)\s*,/); + methodPtr = cast ? cast[1] : direct ? direct[1] : null; + } + + const helperMethod = { + registerGetRoute: "GET", + registerPostRoute: "POST", + }[helperName]; + + const entries = pendingDoc.entries.filter(hasContent); + if (entries.length === 0) entries.push({ ...pendingDoc.entries[0] }); + + for (const entry of entries) { + const route = { + path: entry.manualPath || sniffedPath, + method: entry.manualMethod || helperMethod || sniffedMethod, + tag: entry.tag || pendingDoc.tag || currentTag, + summary: entry.summary, + queries: entry.queries, + pathParams: entry.pathParams, + body: entry.body, + responses: entry.responses, + errors: entry.errors, + }; + + if (!route.path || !route.method) { + warn(`Registration without path/method (helper=${helperName}, line ${i + 1})`); + continue; + } + + // Defaults for template helpers when @doc didn't override. + if (helperName === "registerGetRoute") { + if (route.responses.length === 0) { + const typeName = methodPtr && GET_RESPONSE_TYPES[methodPtr]; + if (typeName) route.responses = [{ kind: "ref", value: typeName }]; + } + if (route.errors.length === 0) { + route.errors = [{ code: 504, description: "robot timeout" }]; + } + } else if (helperName === "registerPostRoute") { + if (route.responses.length === 0) { + route.responses = [{ kind: "ok" }]; + } + if (route.errors.length === 0) { + route.errors = [ + { code: 503, description: "robot busy" }, + { code: 504, description: "robot timeout" }, + ]; + } + } + + routes.push(route); + } + + return consumed; +} + +function hasContent(entry) { + return ( + entry.manualPath || + entry.summary || + entry.queries.length || + entry.pathParams.length || + entry.body || + entry.responses.length || + entry.errors.length + ); +} + +// "name type [enum=a,b,c | int range | bool 0..1] [required] [(notes)]" +function parseParamSpec(value) { + const tokens = tokenize(value.trim()); + const out = { + name: tokens.shift(), + type: "string", + required: false, + enum: null, + notes: null, + }; + while (tokens.length) { + const tok = tokens.shift(); + if (tok === "required") out.required = true; + else if (tok === "boolean") out.type = "boolean"; + else if (tok === "integer") out.type = "integer"; + else if (tok === "number") out.type = "number"; + else if (tok === "string") out.type = "string"; + else if (tok.startsWith("enum=")) { + out.type = "string"; + out.enum = tok.slice(5).split(","); + } else if (/^-?\d+\.\.-?\d+$/.test(tok)) { + const [min, max] = tok.split("..").map(Number); + out.minimum = min; + out.maximum = max; + } else if (tok.startsWith("(") && tok.endsWith(")")) { + out.notes = tok.slice(1, -1); + } + } + return out; +} + +// Splits on whitespace but keeps "(...)" groups intact. +function tokenize(s) { + const out = []; + let i = 0; + while (i < s.length) { + while (i < s.length && /\s/.test(s[i])) i++; + if (i >= s.length) break; + if (s[i] === "(") { + const end = s.indexOf(")", i); + if (end < 0) { + out.push(s.slice(i)); + break; + } + out.push(s.slice(i, end + 1)); + i = end + 1; + } else { + let j = i; + while (j < s.length && !/\s/.test(s[j])) j++; + out.push(s.slice(i, j)); + i = j; + } + } + return out; +} + +function parseBodySpec(value) { + const trimmed = value.trim(); + if (/^multipart\/form-data/i.test(trimmed)) { + return { kind: "multipart", description: trimmed }; + } + const refMatch = trimmed.match(/^([A-Z][A-Za-z0-9_]*)\s*(?:\(partial\))?$/); + if (refMatch) { + return { kind: "ref", ref: refMatch[1], partial: /\(partial\)/.test(trimmed) }; + } + return { kind: "raw", description: trimmed }; +} + +function parseResponseSpec(value) { + const trimmed = value.trim(); + if (trimmed === "Ok") return { kind: "ok" }; + const arrMatch = trimmed.match(/^array of ([A-Z][A-Za-z0-9_]*)$/); + if (arrMatch) return { kind: "array", ref: arrMatch[1] }; + if (/^[A-Z][A-Za-z0-9_]*$/.test(trimmed)) return { kind: "ref", value: trimmed }; + return { kind: "raw", description: trimmed }; +} + +function parseErrorsSpec(value) { + // Split on commas, but ignore commas inside parentheses so descriptions + // like "400=update failed (bad hash, write error)" stay intact. + const parts = []; + let depth = 0; + let cur = ""; + for (const ch of value) { + if (ch === "(") depth++; + else if (ch === ")") depth--; + if (ch === "," && depth === 0) { + parts.push(cur); + cur = ""; + } else { + cur += ch; + } + } + if (cur) parts.push(cur); + + return parts + .map((s) => s.trim()) + .filter(Boolean) + .map((part) => { + const m = part.match(/^(\d{3})\s*=\s*(.+)$/); + if (!m) { + warn(`Malformed error spec: ${part}`); + return null; + } + return { code: Number(m[1]), description: m[2].trim() }; + }) + .filter(Boolean); +} + +// -- TypeScript types extraction -------------------------------------------- + +function parseTypes() { + const program = ts.createProgram([tsPath], { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + strict: true, + noEmit: true, + }); + const checker = program.getTypeChecker(); + const sourceFile = program.getSourceFile(tsPath); + if (!sourceFile) throw new Error(`Could not load ${tsPath}`); + + const interfaces = {}; + ts.forEachChild(sourceFile, (node) => { + if (ts.isInterfaceDeclaration(node)) { + interfaces[node.name.text] = describeInterface(node, checker); + } else if (ts.isTypeAliasDeclaration(node)) { + interfaces[node.name.text] = describeTypeAlias(node); + } + }); + return interfaces; +} + +function describeInterface(node, checker) { + const description = jsDocText(node); + const properties = node.members + .filter(ts.isPropertySignature) + .map((member) => describeProperty(member, checker)) + .filter(Boolean); + return { kind: "object", description, properties }; +} + +function describeTypeAlias(node) { + return { kind: "alias", description: jsDocText(node), text: node.type.getText() }; +} + +function describeProperty(member, checker) { + const name = member.name.getText(); + const optional = !!member.questionToken; + const description = jsDocText(member); + const schema = typeNodeToSchema(member.type, checker); + return { name, optional, description, schema }; +} + +function typeNodeToSchema(typeNode, checker) { + if (!typeNode) return { type: "string" }; + + switch (typeNode.kind) { + case ts.SyntaxKind.StringKeyword: + return { type: "string" }; + case ts.SyntaxKind.NumberKeyword: + return { type: "number" }; + case ts.SyntaxKind.BooleanKeyword: + return { type: "boolean" }; + case ts.SyntaxKind.NullKeyword: + return { type: "null" }; + } + + if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) { + return { type: "string", const: typeNode.literal.text }; + } + + if (ts.isUnionTypeNode(typeNode)) { + const parts = typeNode.types.map((t) => typeNodeToSchema(t, checker)); + const stringLiterals = parts.filter((p) => p.type === "string" && p.const !== undefined); + const hasNull = parts.some((p) => p.type === "null"); + const nonNull = parts.filter((p) => p.type !== "null"); + if (stringLiterals.length === parts.length) { + return { type: "string", enum: stringLiterals.map((p) => p.const) }; + } + if (nonNull.length === 1) { + return { ...nonNull[0], nullable: hasNull }; + } + return { oneOf: parts }; + } + + if (ts.isArrayTypeNode(typeNode)) { + return { type: "array", items: typeNodeToSchema(typeNode.elementType, checker) }; + } + + if (ts.isTupleTypeNode(typeNode)) { + return { + type: "array", + prefixItems: typeNode.elements.map((e) => typeNodeToSchema(ts.isNamedTupleMember(e) ? e.type : e, checker)), + }; + } + + if (ts.isTypeReferenceNode(typeNode)) { + return { ref: typeNode.typeName.getText() }; + } + + return { type: "string" }; +} + +function jsDocText(node) { + const docs = ts.getJSDocCommentsAndTags(node); + if (!docs.length) return null; + const parts = docs + .map((d) => { + if (typeof d.comment === "string") return d.comment; + if (Array.isArray(d.comment)) return d.comment.map((c) => (typeof c === "string" ? c : c.text)).join(""); + return ""; + }) + .filter(Boolean); + const text = parts.join(" ").trim(); + return text || null; +} + +// -- OpenAPI builder --------------------------------------------------------- + +function buildOpenApi(routes, types) { + const tagsUsed = new Set(); + for (const r of routes) if (r.tag) tagsUsed.add(r.tag); + + const openapi = { + openapi: "3.0.3", + info: { + title: "OpenNeato API", + version: VERSION, + 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: [...tagsUsed].map((name) => ({ name, description: TAG_DEFAULTS[name] || "" })), + paths: {}, + components: { + schemas: buildSchemas(types, routes), + responses: { + Ok: { + description: "Success acknowledgement", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Ok" }, + }, + }, + }, + }, + }, + }; + + for (const route of routes) { + if (!openapi.paths[route.path]) openapi.paths[route.path] = {}; + const op = {}; + if (route.tag) op.tags = [route.tag]; + if (route.summary) op.summary = route.summary; + const params = buildParameters(route); + if (params.length) op.parameters = params; + const body = buildRequestBody(route); + if (body) op.requestBody = body; + op.responses = buildResponses(route); + openapi.paths[route.path][route.method.toLowerCase()] = op; + } + + return openapi; +} + +function buildSchemas(types, routes) { + const referenced = collectReferenced(routes, types); + const schemas = { + Ok: { + type: "object", + properties: { ok: { type: "boolean" } }, + required: ["ok"], + }, + Error: { + type: "object", + properties: { error: { type: "string" } }, + required: ["error"], + }, + }; + for (const name of referenced) { + const def = types[name]; + if (!def || def.kind !== "object") continue; + schemas[name] = objectToSchema(def); + } + return schemas; +} + +function collectReferenced(routes, types) { + const out = new Set(); + const visit = (n) => { + if (out.has(n)) return; + out.add(n); + const def = types[n]; + if (!def || def.kind !== "object") return; + for (const prop of def.properties) walkSchema(prop.schema, visit); + }; + for (const route of routes) { + if (route.body?.kind === "ref") visit(route.body.ref); + for (const r of route.responses || []) { + if (r.kind === "ref") visit(r.value); + if (r.kind === "array") visit(r.ref); + } + } + return out; +} + +function walkSchema(node, visit) { + if (!node) return; + if (node.ref) visit(node.ref); + if (node.items) walkSchema(node.items, visit); + if (node.prefixItems) for (const i of node.prefixItems) walkSchema(i, visit); + if (node.oneOf) for (const o of node.oneOf) walkSchema(o, visit); +} + +function objectToSchema(def) { + const properties = {}; + const required = []; + for (const prop of def.properties) { + properties[prop.name] = propertyToSchema(prop); + if (!prop.optional) required.push(prop.name); + } + const out = { type: "object", properties }; + if (def.description) out.description = def.description; + if (required.length) out.required = required; + return out; +} + +function propertyToSchema(prop) { + const schema = schemaShape(prop.schema); + if (prop.description) schema.description = prop.description; + return schema; +} + +function schemaShape(node) { + if (!node) return { type: "string" }; + if (node.ref) return { $ref: `#/components/schemas/${node.ref}` }; + if (node.oneOf) return { oneOf: node.oneOf.map(schemaShape) }; + if (node.type === "array") { + const out = { type: "array" }; + if (node.items) out.items = schemaShape(node.items); + // OpenAPI 3.0 doesn't support prefixItems; collapse to items of unknown + // type for tuple-shaped schemas. (Only used for MapCoverageCell which + // isn't part of any HTTP response in practice.) + if (node.prefixItems) out.items = {}; + return out; + } + const out = { type: node.type }; + if (node.const !== undefined) out.enum = [node.const]; + if (node.enum) out.enum = node.enum; + if (node.nullable) out.nullable = true; + return out; +} + +function buildParameters(route) { + const params = []; + for (const p of route.pathParams) { + params.push({ + name: p.name, + in: "path", + required: true, + description: p.notes || undefined, + schema: paramSchema(p), + }); + } + for (const q of route.queries) { + params.push({ + name: q.name, + in: "query", + required: !!q.required, + description: q.notes || undefined, + schema: paramSchema(q), + }); + } + return params.map(stripUndefined); +} + +function paramSchema(p) { + const out = { type: p.type }; + if (p.enum) out.enum = p.enum; + if (p.minimum !== undefined) out.minimum = p.minimum; + if (p.maximum !== undefined) out.maximum = p.maximum; + return out; +} + +function buildRequestBody(route) { + if (!route.body) return null; + if (route.body.kind === "ref") { + return { + required: true, + content: { + "application/json": { + schema: { $ref: `#/components/schemas/${route.body.ref}` }, + }, + }, + }; + } + if (route.body.kind === "multipart") { + return { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { file: { type: "string", format: "binary" } }, + required: ["file"], + }, + }, + }, + }; + } + return null; +} + +function buildResponses(route) { + const out = {}; + const responses = route.responses || []; + if (responses.length === 0) { + out["200"] = { $ref: "#/components/responses/Ok" }; + } else { + for (const r of responses) { + if (r.kind === "ok") { + out["200"] = { $ref: "#/components/responses/Ok" }; + } else if (r.kind === "ref") { + out["200"] = jsonResponse({ $ref: `#/components/schemas/${r.value}` }); + } else if (r.kind === "array") { + out["200"] = jsonResponse({ + type: "array", + items: { $ref: `#/components/schemas/${r.ref}` }, + }); + } else if (r.kind === "raw") { + const ct = r.description.split(/\s+/)[0]; + out["200"] = { + description: r.description, + content: { [ct]: { schema: { type: "string" } } }, + }; + } + } + } + for (const e of route.errors) { + out[String(e.code)] = { + description: e.description, + content: { + "application/json": { schema: { $ref: "#/components/schemas/Error" } }, + }, + }; + } + return out; +} + +function jsonResponse(schema) { + return { + description: "OK", + content: { "application/json": { schema } }, + }; +} + +function stripUndefined(obj) { + if (Array.isArray(obj)) return obj.map(stripUndefined); + if (obj && typeof obj === "object") { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (v === undefined) continue; + out[k] = stripUndefined(v); + } + return out; + } + return obj; +} + +// -- Markdown renderer (operates on the validated OpenAPI document) --------- + +function renderMarkdown(spec) { + const lines = []; + lines.push(`# ${spec.info.title}`); + lines.push(""); + lines.push(`Version: \`${spec.info.version}\``); + lines.push(""); + if (spec.info.description) lines.push(spec.info.description); + lines.push(""); + lines.push("## Base URL"); + lines.push(""); + for (const s of spec.servers || []) { + lines.push(`- \`${s.url}\` ${s.description ? `- ${s.description}` : ""}`); + } + lines.push(""); + + // Group operations by tag, preserving the order tags appear in the spec. + const byTag = new Map(); + for (const tag of spec.tags || []) byTag.set(tag.name, []); + byTag.set("Other", []); + + for (const [pathKey, pathItem] of Object.entries(spec.paths)) { + for (const method of ["get", "post", "put", "delete", "patch"]) { + const op = pathItem[method]; + if (!op) continue; + const tag = op.tags?.[0] || "Other"; + if (!byTag.has(tag)) byTag.set(tag, []); + byTag.get(tag).push({ method: method.toUpperCase(), path: pathKey, op }); + } + } + + lines.push("## Endpoints"); + lines.push(""); + + for (const [tagName, ops] of byTag) { + if (ops.length === 0) continue; + const tagDef = (spec.tags || []).find((t) => t.name === tagName); + lines.push(`### ${tagName}`); + if (tagDef?.description) lines.push(tagDef.description); + lines.push(""); + for (const { method, path: routePath, op } of ops) { + renderOperation(method, routePath, op, spec, lines); + } + } + + lines.push("## Schemas"); + lines.push(""); + const schemas = spec.components?.schemas || {}; + const schemaNames = Object.keys(schemas).sort(); + for (const name of schemaNames) { + renderSchema(name, schemas[name], lines); + } + + return lines.join("\n"); +} + +function renderOperation(method, routePath, op, spec, lines) { + lines.push(`#### \`${method} ${routePath}\``); + lines.push(""); + if (op.summary) { + lines.push(op.summary); + lines.push(""); + } + if (op.parameters?.length) { + lines.push("**Parameters**"); + lines.push(""); + lines.push("| Name | In | Type | Required | Description |"); + lines.push("|------|----|------|----------|-------------|"); + for (const p of op.parameters) { + lines.push( + `| \`${p.name}\` | ${p.in} | ${formatParamSchema(p.schema)} | ${p.required ? "yes" : "no"} | ${escapeMd(p.description || "")} |`, + ); + } + lines.push(""); + } + if (op.requestBody) { + lines.push("**Request body**"); + lines.push(""); + const content = op.requestBody.content || {}; + const ct = Object.keys(content)[0]; + if (ct === "application/json") { + const ref = content[ct]?.schema?.$ref; + if (ref) { + const name = ref.split("/").pop(); + lines.push(`JSON object matching [\`${name}\`](#${name.toLowerCase()}).`); + } else { + lines.push("JSON body."); + } + } else if (ct === "multipart/form-data") { + lines.push("`multipart/form-data` with a `file` field containing the upload payload."); + } else if (ct) { + lines.push(`Content type \`${ct}\`.`); + } + lines.push(""); + } + lines.push("**Responses**"); + lines.push(""); + lines.push("| Status | Description | Body |"); + lines.push("|--------|-------------|------|"); + for (const [status, resp] of Object.entries(op.responses || {})) { + const resolved = resolveResponse(resp, spec); + const body = describeResponseBody(resolved); + lines.push(`| ${status} | ${escapeMd(resolved.description || "")} | ${body} |`); + } + lines.push(""); +} + +function resolveResponse(resp, spec) { + if (!resp.$ref) return resp; + const name = resp.$ref.split("/").pop(); + return spec.components?.responses?.[name] || { description: "" }; +} + +function describeResponseBody(resp) { + if (!resp.content) return ""; + const ct = Object.keys(resp.content)[0]; + if (!ct) return ""; + const schema = resp.content[ct]?.schema; + if (!schema) return `\`${ct}\``; + if (schema.$ref) { + const name = schema.$ref.split("/").pop(); + return `[\`${name}\`](#${name.toLowerCase()})`; + } + if (schema.type === "array" && schema.items?.$ref) { + const name = schema.items.$ref.split("/").pop(); + return `array of [\`${name}\`](#${name.toLowerCase()})`; + } + if (schema.type === "object" && schema.properties) { + const fields = Object.keys(schema.properties) + .map((k) => `\`${k}\``) + .join(", "); + return `object: ${fields}`; + } + return `\`${ct}\``; +} + +function renderSchema(name, schema, lines) { + lines.push(`### \`${name}\``); + if (schema.description) { + lines.push(""); + lines.push(schema.description); + } + lines.push(""); + if (schema.type === "object" && schema.properties) { + const required = new Set(schema.required || []); + lines.push("| Field | Type | Required | Description |"); + lines.push("|-------|------|----------|-------------|"); + for (const [field, fs] of Object.entries(schema.properties)) { + lines.push( + `| \`${field}\` | ${formatPropSchema(fs)} | ${required.has(field) ? "yes" : "no"} | ${escapeMd(fs.description || "")} |`, + ); + } + } + lines.push(""); +} + +function formatParamSchema(schema) { + if (!schema) return "string"; + if (schema.enum) return `${schema.type} (\`${schema.enum.join("\\|")}\`)`; + let out = schema.type || "string"; + if (schema.minimum !== undefined || schema.maximum !== undefined) { + out += ` (${schema.minimum ?? "-∞"}..${schema.maximum ?? "∞"})`; + } + return out; +} + +function formatPropSchema(schema) { + if (!schema) return "any"; + if (schema.$ref) { + const n = schema.$ref.split("/").pop(); + return `[\`${n}\`](#${n.toLowerCase()})`; + } + if (schema.type === "array") { + if (schema.items?.$ref) { + const n = schema.items.$ref.split("/").pop(); + return `array of [\`${n}\`](#${n.toLowerCase()})`; + } + if (schema.items?.type) return `array of ${schema.items.type}`; + return "array"; + } + if (schema.enum) return `${schema.type} (\`${schema.enum.join("\\|")}\`)`; + if (schema.oneOf) return schema.oneOf.map(formatPropSchema).join(" \\| "); + let out = schema.type || "any"; + if (schema.nullable) out += " \\| null"; + return out; +} + +function escapeMd(s) { + return String(s).replace(/\|/g, "\\|"); +} + +// -- Helpers ----------------------------------------------------------------- + +function readFirmwareVersion() { + try { + const ini = fs.readFileSync(path.join(repoRoot, "platformio.ini"), "utf8"); + const m = ini.match(/FIRMWARE_VERSION\s*=\s*"?([^"\n\s]+)"?/); + if (m) return m[1]; + } catch {} + return "0.0.0-dev"; +} + +function warn(msg) { + process.stderr.write(`[gen_api_docs] WARN: ${msg}\n`); +} + +// -- Main -------------------------------------------------------------------- + +function main() { + fs.mkdirSync(outDir, { recursive: true }); + + const routes = parseCpp(); + const types = parseTypes(); + + const openapi = buildOpenApi(routes, types); + fs.writeFileSync(path.join(outDir, "openapi.json"), `${JSON.stringify(openapi, null, 2)}\n`); + + const markdown = renderMarkdown(openapi); + fs.writeFileSync(path.join(outDir, "api-reference.md"), markdown); + + process.stdout.write( + `[gen_api_docs] Wrote ${routes.length} routes, ${Object.keys(types).length} types -> ${path.relative( + repoRoot, + outDir, + )}\n`, + ); +} + +try { + main(); +} catch (err) { + process.stderr.write(`[gen_api_docs] ${err.stack || err.message}\n`); + process.exit(1); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f0f1251..555f427 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,71 +1,170 @@ +export interface VersionData { + /** Robot model name (e.g. "Botvac D7") */ + modelName: string; + /** Robot serial number */ + serialNumber: string; + /** Robot firmware version, format "Major.Minor.Build" */ + softwareVersion: string; + /** LIDAR firmware version */ + ldsVersion: string; + /** LIDAR serial number */ + ldsSerial: string; + /** Main board hardware version */ + mainBoardVersion: string; +} + +export interface MotorData { + /** Main brush rotation speed in RPM */ + brushRPM: number; + /** Main brush current draw in mA */ + brushMA: number; + /** Vacuum motor speed in RPM */ + vacuumRPM: number; + /** Vacuum motor current draw in mA */ + vacuumMA: number; + /** Left wheel rotation speed in RPM */ + leftWheelRPM: number; + /** Left wheel load percentage */ + leftWheelLoad: number; + /** Left wheel position in millimeters since boot */ + leftWheelPositionMM: number; + /** Left wheel speed in mm/s */ + leftWheelSpeed: number; + /** Right wheel rotation speed in RPM */ + rightWheelRPM: number; + /** Right wheel load percentage */ + rightWheelLoad: number; + /** Right wheel position in millimeters since boot */ + rightWheelPositionMM: number; + /** Right wheel speed in mm/s */ + rightWheelSpeed: number; + /** Side brush current draw in mA */ + sideBrushMA: number; + /** Laser turret rotation speed in RPM */ + laserRPM: number; +} + export interface StateData { + /** UI state machine value, e.g. "UIMGR_STATE_STANDBY" */ uiState: string; + /** Robot state machine value, e.g. "ST_C_Standby" */ robotState: string; } export interface ChargerData { + /** Battery charge percentage (0-100, -1 = unknown) */ fuelPercent: number; + /** Battery temperature exceeds safe limits */ batteryOverTemp: boolean; + /** Charger currently delivering current */ chargingActive: boolean; + /** Charging circuit enabled (independent of active flow) */ chargingEnabled: boolean; + /** Fuel gauge reading is trusted */ confidOnFuel: boolean; + /** Battery has dropped into reserve range */ onReservedFuel: boolean; + /** Battery considered empty (robot will not start) */ emptyFuel: boolean; + /** Battery hardware fault detected */ batteryFailure: boolean; + /** External power supply (dock or barrel jack) connected */ extPwrPresent: boolean; + /** Battery voltage in volts */ vBattV: number; + /** External supply voltage in volts */ vExtV: number; + /** Cumulative mAh delivered into the battery */ chargerMAH: number; + /** Cumulative mAh discharged from the battery */ dischargeMAH: number; } export interface ErrorData { + /** True if the robot is currently reporting an error or warning */ hasError: boolean; + /** "error" for codes 243+, "warning" for codes 201-242 */ kind: "error" | "warning"; + /** Numeric error/warning code (200 = no error) */ errorCode: number; + /** Full raw response from the robot (for diagnostics) */ errorMessage: string; + /** Human-readable message suitable for UI and notifications */ displayMessage: string; } export interface SystemData { + /** Free heap memory in bytes */ heap: number; + /** Total heap memory in bytes */ heapTotal: number; + /** Milliseconds since boot */ uptime: number; + /** WiFi signal strength in dBm (negative, closer to 0 = stronger) */ rssi: number; + /** SPIFFS bytes used */ fsUsed: number; + /** SPIFFS total capacity in bytes */ fsTotal: number; + /** NTP has successfully synced at least once */ ntpSynced: boolean; + /** Current Unix epoch time in seconds (UTC) */ time: number; + /** Time source: "ntp", "robot", or "boot" */ timeSource: string; + /** Configured IANA timezone (e.g. "America/New_York") */ tz: string; - localTime: string; // DST-aware local time, e.g. "Sat 17:45:01" - isDst: boolean; // true when daylight saving time is active + /** DST-aware local time, e.g. "Sat 17:45:01" */ + localTime: string; + /** True when daylight saving time is active */ + isDst: boolean; } // Per-day schedule fields (Mon=0..Sun=6), two slots per day. // Slot 0 (primary): sched{0-6}Hour, sched{0-6}Min, sched{0-6}On // Slot 1 (secondary): sched{0-6}Slot1Hour, sched{0-6}Slot1Min, sched{0-6}Slot1On export interface SettingsData { + /** IANA timezone identifier (e.g. "America/New_York") */ tz: string; - logLevel: number; // 0=off, 1=info, 2=debug - syslogEnabled: boolean; // When on, logs go to UDP syslog instead of flash - syslogIp: string; // IPv4 address of syslog receiver - wifiTxPower: number; // 0.25 dBm units (e.g. 34 = 8.5 dBm) + /** 0=off, 1=info, 2=debug */ + logLevel: number; + /** When on, logs go to UDP syslog instead of flash */ + syslogEnabled: boolean; + /** IPv4 address of syslog receiver */ + syslogIp: string; + /** WiFi TX power in 0.25 dBm units (e.g. 34 = 8.5 dBm) */ + wifiTxPower: number; + /** GPIO pin used for UART TX to the robot */ uartTxPin: number; + /** GPIO pin used for UART RX from the robot */ uartRxPin: number; - maxGpioPin: number; // Read-only — max valid GPIO for this chip (21 for C3, 39 for ESP32) + /** Read-only - max valid GPIO for this chip (21 for C3, 39 for ESP32) */ + maxGpioPin: number; + /** mDNS hostname (e.g. "openneato") */ hostname: string; - navMode: string; // Navigation mode for house cleaning: "Normal", "Gentle", "Deep", "Quick" - stallThreshold: number; // Wheel load % for stall detection (30-80) - brushRpm: number; // Main brush RPM (500-1600) - vacuumSpeed: number; // Vacuum speed % (40-100) - sideBrushPower: number; // Side brush power in mW (500-1500) - ntfyTopic: string; // ntfy.sh topic for push notifications (empty = disabled) - ntfyEnabled: boolean; // Global switch — must be on for any notification to fire - ntfyOnDone: boolean; // Notify when cleaning completes - ntfyOnError: boolean; // Notify on robot error (UI_ERROR_*, code 243+) - ntfyOnAlert: boolean; // Notify on robot alert (UI_ALERT_*, code 201-242) - ntfyOnDocking: boolean; // Notify when robot returns to base + /** Navigation mode for house cleaning: "Normal", "Gentle", "Deep", "Quick" */ + navMode: string; + /** Wheel load % for stall detection (30-80) */ + stallThreshold: number; + /** Main brush RPM (500-1600) */ + brushRpm: number; + /** Vacuum speed % (40-100) */ + vacuumSpeed: number; + /** Side brush power in mW (500-1500) */ + sideBrushPower: number; + /** ntfy.sh topic for push notifications (empty = disabled) */ + ntfyTopic: string; + /** Global switch - must be on for any notification to fire */ + ntfyEnabled: boolean; + /** Notify when cleaning completes */ + ntfyOnDone: boolean; + /** Notify on robot error (UI_ERROR_*, code 243+) */ + ntfyOnError: boolean; + /** Notify on robot alert (UI_ALERT_*, code 201-242) */ + ntfyOnAlert: boolean; + /** Notify when robot returns to base */ + ntfyOnDocking: boolean; + /** Master switch for the weekly cleaning schedule */ scheduleEnabled: boolean; // Slot 0 (primary) sched0Hour: number; @@ -114,82 +213,138 @@ export interface SettingsData { } export interface UserSettingsData { + /** Play a click sound on button presses */ buttonClick: boolean; + /** Play melodies (start, finish, etc.) */ melodies: boolean; + /** Play warning chimes */ warnings: boolean; + /** Reduced power cleaning (longer runtime, lower suction) */ ecoMode: boolean; + /** Maximum power cleaning */ intenseClean: boolean; + /** Stop and warn when dirt bin is full */ binFullDetect: boolean; + /** Enable wall following along walls and edges */ wallEnable: boolean; + /** Robot WiFi radio enabled (separate from bridge WiFi) */ wifi: boolean; + /** Dim status LEDs at night */ stealthLed: boolean; - filterChange: number; // seconds - brushChange: number; // seconds - dirtBin: number; // minutes + /** Filter change reminder interval in seconds */ + filterChange: number; + /** Brush change reminder interval in seconds */ + brushChange: number; + /** Dirt bin reminder interval in minutes */ + dirtBin: number; } export interface LidarPoint { + /** Bearing in degrees (0-359, 0 = robot front) */ angle: number; + /** Distance to target in millimeters (0 if invalid) */ dist: number; + /** Return signal strength (0-255) */ intensity: number; + /** Per-point error code (0 = valid) */ error: number; } export interface LidarScan { + /** LIDAR turret rotation speed in Hz */ rotationSpeed: number; + /** Count of points with error == 0 */ validPoints: number; + /** Always 360 entries indexed by angle */ points: LidarPoint[]; } export interface FirmwareVersion { + /** Bridge firmware semantic version */ version: string; + /** ESP32 chip model (e.g. "ESP32-C3") */ chip: string; + /** Robot model name (e.g. "Botvac D7", empty until identified) */ + model: string; + /** mDNS hostname (e.g. "openneato") */ hostname: string; + /** True if the connected robot model is officially supported */ supported: boolean; + /** True while the bridge is still probing the robot model */ identifying: boolean; } export interface ManualStatus { + /** Manual mode currently engaged */ active: boolean; + /** Main brush motor enabled */ brush: boolean; + /** Vacuum motor enabled */ vacuum: boolean; + /** Side brush motor enabled */ sideBrush: boolean; + /** Robot wheel-drop sensor reports the robot is off the ground */ lifted: boolean; + /** Front-left bumper contacted */ bumperFrontLeft: boolean; + /** Front-right bumper contacted */ bumperFrontRight: boolean; + /** Left side bumper contacted */ bumperSideLeft: boolean; + /** Right side bumper contacted */ bumperSideRight: boolean; + /** Front wheel stall detected */ stallFront: boolean; + /** Rear wheel stall detected */ stallRear: boolean; } export interface LogFileInfo { + /** File name as stored on flash (may include .hs compression suffix) */ name: string; + /** File size in bytes */ size: number; + /** True if stored with heatshrink compression */ compressed: boolean; } export interface MapSession { + /** Always "session" - identifies the record type */ type: "session"; + /** Cleaning mode: "House", "Spot", or "Manual" */ mode: string; + /** Unix epoch seconds when the session started */ time: number; + /** Battery percentage at session start */ battery: number; } export interface MapSummary { + /** Always "summary" - identifies the record type */ type: "summary"; + /** Unix epoch seconds when the session ended */ time: number; + /** Session duration in seconds */ duration: number; + /** Cleaning mode: "House", "Spot", or "Manual" */ mode: string; + /** Number of times the robot returned to base to recharge */ recharges: number; + /** Number of pose snapshots captured */ snapshots: number; + /** Total distance traveled in meters */ distanceTraveled: number; + /** Maximum distance from origin in meters */ maxDistFromOrigin: number; + /** Total rotation in degrees */ totalRotation: number; + /** Estimated area covered in square meters */ areaCovered: number; + /** Number of errors encountered during cleaning */ errorsDuringClean: number; - battery?: number; // Legacy: same as batteryStart (pre-fix firmware) + /** Battery percentage at session start */ batteryStart?: number; + /** Battery percentage at session end */ batteryEnd?: number; } @@ -241,10 +396,16 @@ export interface MapData { } export interface HistoryFileInfo { + /** File name as stored on flash (may include .hs compression suffix) */ name: string; + /** File size in bytes */ size: number; + /** True if stored with heatshrink compression */ compressed: boolean; + /** True if this session is currently being recorded */ recording: boolean; + /** Session metadata, or null if not yet written */ session: MapSession | null; + /** Session summary, or null if cleaning has not finished */ summary: MapSummary | null; } diff --git a/frontend/src/views/history/item.tsx b/frontend/src/views/history/item.tsx index b6d434b..de7c944 100644 --- a/frontend/src/views/history/item.tsx +++ b/frontend/src/views/history/item.tsx @@ -140,7 +140,7 @@ export function HistoryItemView({ file, map, mapEmpty, recording }: HistoryItemV
Battery - {session?.battery ?? "?"}% → {summary.batteryEnd ?? summary.battery ?? "?"}% + {session?.battery ?? "?"}% → {summary.batteryEnd ?? "?"}%
{summary.recharges > 0 && ( From 388c9726283c7c2cef77ae8ff2fbd5032c1ad1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Mon, 27 Apr 2026 09:29:58 +0300 Subject: [PATCH 08/44] refactor: hand-edited OpenAPI YAML as single source of truth --- .github/workflows/release.yml | 4 +- .gitignore | 2 +- .goreleaser.yml | 13 +- AGENTS.md | 13 +- firmware/src/web_server.cpp | 160 --- frontend/api/openapi.yaml | 1529 +++++++++++++++++++++++++++ frontend/biome.json | 1 + frontend/package-lock.json | 310 +++++- frontend/package.json | 9 +- frontend/scripts/check_api_paths.js | 70 ++ frontend/scripts/gen_api_docs.js | 1013 ------------------ frontend/src/types.ts | 393 +------ 12 files changed, 1960 insertions(+), 1557 deletions(-) create mode 100644 frontend/api/openapi.yaml create mode 100644 frontend/scripts/check_api_paths.js delete mode 100644 frontend/scripts/gen_api_docs.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7c4cf1..7e6f668 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,9 +98,7 @@ jobs: run: npm ci working-directory: frontend - - name: Build frontend (lint + typecheck + vite + embed + api docs) - env: - OPENNEATO_VERSION: ${{ needs.prepare.outputs.version }} + - name: Build frontend (lint + codegen + path-sync + typecheck + vite + embed) run: npm run build working-directory: frontend diff --git a/.gitignore b/.gitignore index d95fef0..579f249 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ firmware/compile_commands.json firmware/src/web_assets.h frontend/node_modules frontend/dist -frontend/dist-docs +frontend/src/types.generated.ts flash/openneato-flash release/ dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 43dc56c..7ef73c6 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 @@ -53,16 +60,14 @@ release: extra_files: - glob: .pio/build/*-release/*-firmware.bin - glob: .pio/build/*-release/*-full.tar.gz - - glob: frontend/dist-docs/openapi.json - - glob: frontend/dist-docs/api-reference.md + - 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/dist-docs/openapi.json - - glob: frontend/dist-docs/api-reference.md + - glob: frontend/api/openapi.yaml changelog: sort: asc diff --git a/AGENTS.md b/AGENTS.md index 0c7a218..5eee504 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +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` -> - `gen_api_docs.js` generates `frontend/dist-docs/{openapi.json, api-reference.md}` (shipped as release assets) +- Build pipeline: `npm run build` -> lint -> `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 @@ -46,10 +47,10 @@ are logged; at debug level, all serial commands including raw responses are incl ### API Documentation -When adding/changing an HTTP route, annotate it with `// @doc` directives in -`web_server.cpp` and keep TSDoc (`/** ... */`) on every field of the matching -response/body interface in `frontend/src/types.ts`. The generator (`npm run build`) -fails on drift between firmware JSON keys and TS interfaces. +`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 diff --git a/firmware/src/web_server.cpp b/firmware/src/web_server.cpp index e6535d4..19d07c0 100644 --- a/firmware/src/web_server.cpp +++ b/firmware/src/web_server.cpp @@ -77,78 +77,33 @@ void WebServer::begin() { void WebServer::registerApiRoutes() { // -- Sensor query endpoints ---------------------------------------------- - // @tag Sensors: Read-only telemetry from the robot - // @doc summary: Get robot identity (model, serial, firmware versions) - // @doc response: VersionData registerGetRoute("/api/version", neato, &NeatoSerial::getVersion, {}); - // @doc summary: Get battery and charger status - // @doc response: ChargerData registerGetRoute("/api/charger", neato, &NeatoSerial::getCharger, {}); - // @doc summary: Get motor telemetry (brush, vacuum, wheels, side brush, laser) - // @doc response: MotorData registerGetRoute( "/api/motors", neato, static_cast)>(&NeatoSerial::getMotors), {}); - // @doc summary: Get current UI and robot state machine values - // @doc response: StateData registerGetRoute("/api/state", neato, &NeatoSerial::getState, {}); - // @doc summary: Get current error or warning state - // @doc response: ErrorData registerGetRoute("/api/error", neato, &NeatoSerial::getErr, {}); - // @doc summary: Get latest 360-point LIDAR scan - // @doc response: LidarScan registerGetRoute("/api/lidar", neato, &NeatoSerial::getLdsScan, {}); - // @doc summary: Get robot on-board user settings (sounds, eco, wall follow, maintenance) - // @doc response: UserSettingsData registerGetRoute("/api/user-settings", neato, &NeatoSerial::getUserSettings, {}); // -- Action endpoints ---------------------------------------------------- // All parameterized actions use query strings: resource URL identifies the // command, query params carry arguments (mirrors Neato serial protocol). - // @tag Actions: Control commands sent to the robot - // @doc summary: Start, pause, resume, stop, or dock cleaning - // @doc query: action enum=house,spot,pause,stop,dock required - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/clean", neato, &NeatoSerial::clean, {"action"}); - // @doc summary: Play a robot sound effect - // @doc query: id integer 0..20 required - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/sound", neato, &NeatoSerial::playSound, {"id"}); - // @doc summary: Enter or exit test mode (required for manual control) - // @doc query: enable boolean 0..1 required - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/testmode", neato, &NeatoSerial::testMode, {"enable"}); - // @doc summary: Restart or shutdown the robot - // @doc query: action enum=restart,shutdown required - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/power", neato, &NeatoSerial::powerControl, {"action"}); - // @doc summary: Start or stop LIDAR turret rotation - // @doc query: enable boolean 0..1 required - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/lidar/rotate", neato, &NeatoSerial::setLdsRotation, {"enable"}); - // @doc summary: Set a single robot on-board user setting - // @doc query: key string required - // @doc query: value string required - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout registerPostRoute("/api/user-settings", neato, &NeatoSerial::setUserSetting, {"key", "value"}); - // @doc summary: Clear all UI errors and warnings on the robot - // @doc response: Ok - // @doc errors: 503=robot busy, 504=robot timeout 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). // Excluded from public API docs (diagnostics-only passthrough). - // @doc skip server.on("/api/serial", HTTP_POST, [this](AsyncWebServerRequest *request) { lastApiActivity = millis(); unsigned long startMs = lastApiActivity; @@ -187,35 +142,14 @@ void WebServer::registerApiRoutes() { void WebServer::registerManualRoutes() { // Register longer paths first — ESPAsyncWebServer matches routes by prefix, // so /api/manual would swallow /api/manual/move and /api/manual/motors. - // @tag Manual: Manual cleaning mode (joystick, motors) - // @doc path: /api/manual/status - // @doc method: GET - // @doc summary: Get manual mode state (active flag, motors, bumpers, stalls) - // @doc response: ManualStatus loggedRoute("/api/manual/status", HTTP_GET, [this](AsyncWebServerRequest *request) { request->send(200, "application/json", manualMgr.getStatusJson()); return 200; }); - // @doc summary: Drive the wheels for a specific distance at a given speed - // @doc query: left integer required (mm, negative=backward) - // @doc query: right integer required (mm, negative=backward) - // @doc query: speed integer required (mm/s) - // @doc response: Ok - // @doc errors: 503=manual mode inactive or unsafe state, 504=robot timeout registerPostRoute("/api/manual/move", manualMgr, &ManualCleanManager::move, {"left", "right", "speed"}); - // @doc summary: Toggle main brush, vacuum, and side brush motors - // @doc query: brush boolean 0..1 required - // @doc query: vacuum boolean 0..1 required - // @doc query: sideBrush boolean 0..1 required - // @doc response: Ok - // @doc errors: 503=manual mode inactive, 504=robot timeout registerPostRoute("/api/manual/motors", manualMgr, &ManualCleanManager::setMotors, {"brush", "vacuum", "sideBrush"}); - // @doc summary: Enable or disable manual mode (also enters TestMode and starts LIDAR) - // @doc query: enable boolean 0..1 required - // @doc response: Ok - // @doc errors: 503=cannot enter manual mode, 504=robot timeout registerPostRoute("/api/manual", manualMgr, &ManualCleanManager::enable, {"enable"}); LOG("WEB", "Manual clean routes registered"); @@ -242,23 +176,12 @@ static String logListJson(const std::vector& files) { } void WebServer::registerLogRoutes() { - // @tag Logs: Diagnostic log files stored in flash // 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. - // @doc path: /api/logs - // @doc method: GET - // @doc summary: List all log files - // @doc response: array of LogFileInfo - // @doc path: /api/logs/{filename} - // @doc method: GET - // @doc summary: Download a single log file (transparently decompressed) - // @doc param: filename string required - // @doc response: application/x-ndjson - // @doc errors: 404=log not found server.on("/api/logs", HTTP_GET, [this](AsyncWebServerRequest *request) { unsigned long startMs = millis(); String filename = request->url().substring(String("/api/logs/").length()); @@ -291,16 +214,6 @@ void WebServer::registerLogRoutes() { }); // DELETE /api/logs[/filename] — delete all logs or a specific file - // @doc path: /api/logs - // @doc method: DELETE - // @doc summary: Delete all log files - // @doc response: Ok - // @doc path: /api/logs/{filename} - // @doc method: DELETE - // @doc summary: Delete a single log file - // @doc param: filename string required - // @doc response: Ok - // @doc errors: 404=log not found loggedRoute("/api/logs", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { String filename = request->url().substring(String("/api/logs/").length()); @@ -325,23 +238,14 @@ void WebServer::registerLogRoutes() { // -- System health endpoint --------------------------------------------------- void WebServer::registerSystemRoutes() { - // @tag System: ESP32 system health and lifecycle // GET /api/system — live system health (heap, uptime, RSSI, storage, NTP) - // @doc path: /api/system - // @doc method: GET - // @doc summary: Get live system metrics (heap, uptime, WiFi RSSI, storage, NTP, time) - // @doc response: SystemData 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 - // @doc path: /api/system/restart - // @doc method: POST - // @doc summary: Restart the ESP32 (deferred 500ms to flush HTTP response) - // @doc response: Ok loggedRoute("/api/system/restart", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { sendOk(request); sysMgr.restart(); @@ -349,10 +253,6 @@ void WebServer::registerSystemRoutes() { }); // POST /api/system/reset — factory reset (clears NVS + WiFi, then restarts) - // @doc path: /api/system/reset - // @doc method: POST - // @doc summary: Factory reset (clear NVS and WiFi credentials, then restart) - // @doc response: Ok loggedRoute("/api/system/reset", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { sendOk(request); sysMgr.factoryReset(); @@ -360,10 +260,6 @@ void WebServer::registerSystemRoutes() { }); // POST /api/system/format-fs — format filesystem (erases logs + map data, then restarts) - // @doc path: /api/system/format-fs - // @doc method: POST - // @doc summary: Format the SPIFFS filesystem (erases logs and history, then restart) - // @doc response: Ok loggedRoute("/api/system/format-fs", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { sendOk(request); sysMgr.formatFs(); @@ -376,25 +272,14 @@ void WebServer::registerSystemRoutes() { // -- Settings endpoint ------------------------------------------------------- void WebServer::registerSettingsRoutes() { - // @tag Settings: User-configurable bridge settings // GET /api/settings — all user-configurable settings - // @doc path: /api/settings - // @doc method: GET - // @doc summary: Get all bridge settings (timezone, logging, WiFi, navigation, schedule, notifications) - // @doc response: SettingsData 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) - // @doc path: /api/settings - // @doc method: PUT - // @doc summary: Partial settings update (only fields present in body are written) - // @doc body: SettingsData (partial) - // @doc response: SettingsData - // @doc errors: 400=invalid settings loggedBodyRoute("/api/settings", HTTP_PUT, [this](AsyncWebServerRequest *request, uint8_t *data, size_t len) -> int { String body = String(reinterpret_cast(data), len); @@ -416,12 +301,6 @@ void WebServer::registerSettingsRoutes() { }); // POST /api/notifications/test?topic= — send a test notification - // @doc path: /api/notifications/test - // @doc method: POST - // @doc summary: Send a test push notification to the given ntfy.sh topic - // @doc query: topic string required - // @doc response: Ok - // @doc errors: 400=missing or empty topic loggedRoute("/api/notifications/test", HTTP_POST, [this](AsyncWebServerRequest *request) -> int { if (!request->hasParam("topic")) { sendError(request, 400, "missing topic"); @@ -443,13 +322,8 @@ void WebServer::registerSettingsRoutes() { // -- Firmware endpoints ------------------------------------------------------- void WebServer::registerFirmwareRoutes() { - // @tag Firmware: ESP32 firmware version and OTA update // GET /api/firmware/version — current ESP32 firmware version + chip model + robot support status - // @doc path: /api/firmware/version - // @doc method: GET - // @doc summary: Get current ESP32 firmware version, chip model, robot model, and support status - // @doc response: FirmwareVersion loggedRoute("/api/firmware/version", HTTP_GET, [this](AsyncWebServerRequest *request) -> int { std::vector fields = { {"version", fwMgr.getFirmwareVersion(), FIELD_STRING}, @@ -464,13 +338,6 @@ void WebServer::registerFirmwareRoutes() { }); // POST /api/firmware/update?hash= — single-request firmware upload - // @doc path: /api/firmware/update - // @doc method: POST - // @doc summary: Upload a new firmware image and verify against the supplied MD5 - // @doc query: hash string required (MD5 of firmware binary) - // @doc body: multipart/form-data file= - // @doc response: text/plain "OK" - // @doc errors: 400=update failed (bad hash, write error, or MD5 mismatch) server.on( "/api/firmware/update", HTTP_POST, // Response handler (called after upload completes) @@ -527,19 +394,8 @@ void WebServer::registerFirmwareRoutes() { // -- Map data endpoints ------------------------------------------------------- void WebServer::registerMapRoutes() { - // @tag History: Cleaning session history and map data // GET /api/history[/filename] — list sessions, collection status, or download a specific file - // @doc path: /api/history - // @doc method: GET - // @doc summary: List all cleaning session files with embedded session and summary metadata - // @doc response: array of HistoryFileInfo - // @doc path: /api/history/{filename} - // @doc method: GET - // @doc summary: Download a single session file (transparently decompressed) - // @doc param: filename string required - // @doc response: application/x-ndjson - // @doc errors: 404=session not found server.on("/api/history", HTTP_GET, [this](AsyncWebServerRequest *request) { lastApiActivity = millis(); unsigned long startMs = lastApiActivity; @@ -594,16 +450,6 @@ void WebServer::registerMapRoutes() { }); // DELETE /api/history[/filename] — delete one or all sessions - // @doc path: /api/history - // @doc method: DELETE - // @doc summary: Delete all cleaning session files - // @doc response: Ok - // @doc path: /api/history/{filename} - // @doc method: DELETE - // @doc summary: Delete a single cleaning session file - // @doc param: filename string required - // @doc response: Ok - // @doc errors: 404=session not found loggedRoute("/api/history", HTTP_DELETE, [this](AsyncWebServerRequest *request) -> int { String filename = request->url().substring(String("/api/history/").length()); @@ -623,12 +469,6 @@ void WebServer::registerMapRoutes() { }); // POST /api/history/import — upload a .jsonl session file, compress and store - // @doc path: /api/history/import - // @doc method: POST - // @doc summary: Upload a JSONL session file (compressed and stored on flash) - // @doc body: multipart/form-data file= - // @doc response: Ok - // @doc errors: 400=import failed (invalid file or write error) server.on( "/api/history/import", HTTP_POST, // Response handler (called after upload completes) diff --git a/frontend/api/openapi.yaml b/frontend/api/openapi.yaml new file mode 100644 index 0000000..2b0c3a2 --- /dev/null +++ b/frontend/api/openapi.yaml @@ -0,0 +1,1529 @@ +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: 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/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/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/testmode: + post: + tags: + - Actions + summary: Enter or exit test mode (required for manual control) + 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/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/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 + 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 + 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 + 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) + ntfyEnabled: + type: boolean + description: Global switch - must be on for any notification to fire + 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 + - syslogEnabled + - syslogIp + - wifiTxPower + - uartTxPin + - uartRxPin + - maxGpioPin + - hostname + - navMode + - stallThreshold + - brushRpm + - vacuumSpeed + - sideBrushPower + - ntfyTopic + - ntfyEnabled + - 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: + 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 + required: + - version + - chip + - model + - hostname + - supported + - identifying + 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 + responses: + Ok: + description: Success acknowledgement + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" diff --git a/frontend/biome.json b/frontend/biome.json index 67e4982..1f69473 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -4,6 +4,7 @@ "files": { "includes": [ "src/**", + "!src/types.generated.ts", "mock/*.js", "scripts/**", "vite.config.ts", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ce0a779..27221fd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,8 +13,10 @@ "devDependencies": { "@biomejs/biome": "2.4.13", "@preact/preset-vite": "2.10.5", + "openapi-typescript": "^7.13.0", "typescript": "6.0.3", - "vite": "8.0.10" + "vite": "8.0.10", + "yaml": "^2.8.3" } }, "node_modules/@babel/code-frame": { @@ -712,6 +714,52 @@ "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.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -1035,6 +1083,33 @@ "dev": true, "license": "MIT" }, + "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/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/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", @@ -1045,6 +1120,13 @@ "@babel/core": "^7.12.10" } }, + "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", @@ -1065,6 +1147,16 @@ "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/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -1120,6 +1212,20 @@ ], "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/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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1281,6 +1387,13 @@ "dev": true, "license": "MIT" }, + "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/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1334,6 +1447,43 @@ "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/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/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-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1341,6 +1491,19 @@ "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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1354,6 +1517,13 @@ "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", @@ -1667,6 +1837,19 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "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": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1724,6 +1907,45 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "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, + "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": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1744,6 +1966,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.12", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", @@ -1783,6 +2015,16 @@ "url": "https://opencollective.com/preact" } }, + "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", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -1867,6 +2109,19 @@ "node": ">=16" } }, + "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/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1892,6 +2147,19 @@ "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": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1937,6 +2205,13 @@ "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": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -2040,6 +2315,39 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "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/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index d68c33e..b55f8c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,8 +4,9 @@ "version": "0.0.1", "type": "module", "scripts": { - "dev": "vite", - "build": "biome check && tsc --noEmit && vite build && node scripts/embed_frontend.js && node scripts/gen_api_docs.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": "biome check && 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", "fix": "biome check --write", @@ -17,7 +18,9 @@ "devDependencies": { "@biomejs/biome": "2.4.13", "@preact/preset-vite": "2.10.5", + "openapi-typescript": "^7.13.0", "typescript": "6.0.3", - "vite": "8.0.10" + "vite": "8.0.10", + "yaml": "^2.8.3" } } 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/gen_api_docs.js b/frontend/scripts/gen_api_docs.js deleted file mode 100644 index a5c2c42..0000000 --- a/frontend/scripts/gen_api_docs.js +++ /dev/null @@ -1,1013 +0,0 @@ -// Generates an OpenAPI 3.0 spec and a human-readable Markdown reference for -// the HTTP API exposed by the firmware. -// -// Sources: -// - firmware/src/web_server.cpp (route registrations + // @doc directives) -// - frontend/src/types.ts (response/body schemas via TSDoc) -// -// Outputs: -// - frontend/dist-docs/openapi.json -// - frontend/dist-docs/api-reference.md -// -// Usage: node frontend/scripts/gen_api_docs.js - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import ts from "typescript"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.join(__dirname, "..", ".."); -const cppPath = path.join(repoRoot, "firmware", "src", "web_server.cpp"); -const tsPath = path.join(repoRoot, "frontend", "src", "types.ts"); -const outDir = path.join(__dirname, "..", "dist-docs"); - -const VERSION = process.env.OPENNEATO_VERSION || readFirmwareVersion(); - -// Maps the manager method referenced in registerGetRoute(...) to the response -// schema name in types.ts. Static_cast<...> wrappers are stripped before -// lookup. -const GET_RESPONSE_TYPES = { - "&NeatoSerial::getVersion": "VersionData", - "&NeatoSerial::getCharger": "ChargerData", - "&NeatoSerial::getMotors": "MotorData", - "&NeatoSerial::getState": "StateData", - "&NeatoSerial::getErr": "ErrorData", - "&NeatoSerial::getLdsScan": "LidarScan", - "&NeatoSerial::getUserSettings": "UserSettingsData", -}; - -const TAG_DEFAULTS = { - Sensors: "Read-only telemetry from the robot", - Actions: "Control commands sent to the robot", - Manual: "Manual cleaning mode (joystick, motors)", - Logs: "Diagnostic log files stored in flash", - System: "ESP32 system health and lifecycle", - Settings: "User-configurable bridge settings", - Firmware: "ESP32 firmware version and OTA update", - History: "Cleaning session history and map data", -}; - -// -- C++ route extraction ---------------------------------------------------- - -function parseCpp() { - const src = fs.readFileSync(cppPath, "utf8"); - const lines = src.split("\n"); - - const routes = []; - let pendingDoc = null; - let currentTag = null; - - const blankEntry = () => ({ - summary: null, - manualPath: null, - manualMethod: null, - queries: [], - pathParams: [], - body: null, - responses: [], - errors: [], - tag: null, - }); - - const startDoc = () => { - if (!pendingDoc) { - pendingDoc = { - skip: false, - tag: null, - entries: [], - _currentEntry: null, - }; - } - if (!pendingDoc._currentEntry) { - pendingDoc._currentEntry = blankEntry(); - pendingDoc.entries.push(pendingDoc._currentEntry); - } - }; - - const handleDocLine = (raw) => { - const tagMatch = raw.match(/^\s*\/\/\s*@tag\s+([A-Za-z][\w-]*)\s*:\s*(.+?)\s*$/); - if (tagMatch) { - const [, name, desc] = tagMatch; - currentTag = name; - TAG_DEFAULTS[name] = desc; - return; - } - - const docMatch = raw.match(/^\s*\/\/\s*@doc\s+(.*)$/); - if (!docMatch) return; - const rest = docMatch[1].trim(); - - if (rest === "skip") { - startDoc(); - pendingDoc.skip = true; - return; - } - - const colon = rest.indexOf(":"); - if (colon < 0) { - warn(`Malformed @doc directive: ${raw.trim()}`); - return; - } - const key = rest.slice(0, colon).trim().toLowerCase(); - const value = rest.slice(colon + 1).trim(); - - startDoc(); - - // "path:" starts a new entry within the same comment block (e.g. when a - // single server.on() handler covers /api/logs and /api/logs/{filename}). - if (key === "path" && pendingDoc._currentEntry.manualPath) { - pendingDoc._currentEntry = blankEntry(); - pendingDoc.entries.push(pendingDoc._currentEntry); - } - - const entry = pendingDoc._currentEntry; - switch (key) { - case "path": - entry.manualPath = value; - break; - case "method": - entry.manualMethod = value.toUpperCase(); - break; - case "tag": - entry.tag = value; - break; - case "summary": - entry.summary = value; - break; - case "query": - entry.queries.push(parseParamSpec(value)); - break; - case "param": - entry.pathParams.push(parseParamSpec(value)); - break; - case "body": - entry.body = parseBodySpec(value); - break; - case "response": - entry.responses.push(parseResponseSpec(value)); - break; - case "errors": - entry.errors.push(...parseErrorsSpec(value)); - break; - default: - warn(`Unknown @doc key: ${key}`); - } - }; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (/^\s*\/\/\s*@(doc|tag)\b/.test(line)) { - handleDocLine(line); - continue; - } - - if (!pendingDoc) continue; - - if (pendingDoc.skip) { - // Consume the next registration call and forget the pending doc. - if (looksLikeRegistration(line)) pendingDoc = null; - continue; - } - - const consumed = tryConsumeRegistration(lines, i, pendingDoc, currentTag, routes); - if (consumed > 0) { - i += consumed - 1; - pendingDoc = null; - } - } - - return routes; -} - -function looksLikeRegistration(line) { - return /\b(registerGetRoute|registerPostRoute|loggedRoute|loggedBodyRoute|server\.on)\s*\(/.test(line); -} - -// Reads a possibly multi-line registration call starting at lines[i] and, if -// found, appends one or more route records to `routes`. Returns the number of -// source lines consumed (0 if no registration was found on this line). -function tryConsumeRegistration(lines, i, pendingDoc, currentTag, routes) { - if (!looksLikeRegistration(lines[i])) return 0; - - let depth = 0; - let buf = ""; - let consumed = 0; - let started = false; - for (let j = i; j < lines.length && j < i + 30; j++) { - const line = lines[j]; - for (let k = 0; k < line.length; k++) { - const c = line[k]; - if (c === "(") { - depth++; - started = true; - } else if (c === ")") { - depth--; - } - buf += c; - if (started && depth === 0) { - consumed = j - i + 1; - break; - } - } - buf += "\n"; - if (started && depth === 0) break; - consumed = j - i + 1; - } - - const helper = buf.match(/(registerGetRoute|registerPostRoute|loggedRoute|loggedBodyRoute|server\.on)\s*\(/); - if (!helper) return 0; - const helperName = helper[1]; - - const pathMatch = buf.match(/"([^"]+)"/); - const sniffedPath = pathMatch ? pathMatch[1] : null; - - const methodMatch = buf.match(/HTTP_(GET|POST|PUT|DELETE|PATCH)/); - const sniffedMethod = methodMatch ? methodMatch[1] : null; - - let methodPtr = null; - if (helperName === "registerGetRoute" || helperName === "registerPostRoute") { - const cast = buf.match(/static_cast<[^>]+>\s*\(\s*(&[A-Za-z_][\w:]*::[A-Za-z_]\w*)\s*\)/); - const direct = buf.match(/,\s*(&[A-Za-z_][\w:]*::[A-Za-z_]\w*)\s*,/); - methodPtr = cast ? cast[1] : direct ? direct[1] : null; - } - - const helperMethod = { - registerGetRoute: "GET", - registerPostRoute: "POST", - }[helperName]; - - const entries = pendingDoc.entries.filter(hasContent); - if (entries.length === 0) entries.push({ ...pendingDoc.entries[0] }); - - for (const entry of entries) { - const route = { - path: entry.manualPath || sniffedPath, - method: entry.manualMethod || helperMethod || sniffedMethod, - tag: entry.tag || pendingDoc.tag || currentTag, - summary: entry.summary, - queries: entry.queries, - pathParams: entry.pathParams, - body: entry.body, - responses: entry.responses, - errors: entry.errors, - }; - - if (!route.path || !route.method) { - warn(`Registration without path/method (helper=${helperName}, line ${i + 1})`); - continue; - } - - // Defaults for template helpers when @doc didn't override. - if (helperName === "registerGetRoute") { - if (route.responses.length === 0) { - const typeName = methodPtr && GET_RESPONSE_TYPES[methodPtr]; - if (typeName) route.responses = [{ kind: "ref", value: typeName }]; - } - if (route.errors.length === 0) { - route.errors = [{ code: 504, description: "robot timeout" }]; - } - } else if (helperName === "registerPostRoute") { - if (route.responses.length === 0) { - route.responses = [{ kind: "ok" }]; - } - if (route.errors.length === 0) { - route.errors = [ - { code: 503, description: "robot busy" }, - { code: 504, description: "robot timeout" }, - ]; - } - } - - routes.push(route); - } - - return consumed; -} - -function hasContent(entry) { - return ( - entry.manualPath || - entry.summary || - entry.queries.length || - entry.pathParams.length || - entry.body || - entry.responses.length || - entry.errors.length - ); -} - -// "name type [enum=a,b,c | int range | bool 0..1] [required] [(notes)]" -function parseParamSpec(value) { - const tokens = tokenize(value.trim()); - const out = { - name: tokens.shift(), - type: "string", - required: false, - enum: null, - notes: null, - }; - while (tokens.length) { - const tok = tokens.shift(); - if (tok === "required") out.required = true; - else if (tok === "boolean") out.type = "boolean"; - else if (tok === "integer") out.type = "integer"; - else if (tok === "number") out.type = "number"; - else if (tok === "string") out.type = "string"; - else if (tok.startsWith("enum=")) { - out.type = "string"; - out.enum = tok.slice(5).split(","); - } else if (/^-?\d+\.\.-?\d+$/.test(tok)) { - const [min, max] = tok.split("..").map(Number); - out.minimum = min; - out.maximum = max; - } else if (tok.startsWith("(") && tok.endsWith(")")) { - out.notes = tok.slice(1, -1); - } - } - return out; -} - -// Splits on whitespace but keeps "(...)" groups intact. -function tokenize(s) { - const out = []; - let i = 0; - while (i < s.length) { - while (i < s.length && /\s/.test(s[i])) i++; - if (i >= s.length) break; - if (s[i] === "(") { - const end = s.indexOf(")", i); - if (end < 0) { - out.push(s.slice(i)); - break; - } - out.push(s.slice(i, end + 1)); - i = end + 1; - } else { - let j = i; - while (j < s.length && !/\s/.test(s[j])) j++; - out.push(s.slice(i, j)); - i = j; - } - } - return out; -} - -function parseBodySpec(value) { - const trimmed = value.trim(); - if (/^multipart\/form-data/i.test(trimmed)) { - return { kind: "multipart", description: trimmed }; - } - const refMatch = trimmed.match(/^([A-Z][A-Za-z0-9_]*)\s*(?:\(partial\))?$/); - if (refMatch) { - return { kind: "ref", ref: refMatch[1], partial: /\(partial\)/.test(trimmed) }; - } - return { kind: "raw", description: trimmed }; -} - -function parseResponseSpec(value) { - const trimmed = value.trim(); - if (trimmed === "Ok") return { kind: "ok" }; - const arrMatch = trimmed.match(/^array of ([A-Z][A-Za-z0-9_]*)$/); - if (arrMatch) return { kind: "array", ref: arrMatch[1] }; - if (/^[A-Z][A-Za-z0-9_]*$/.test(trimmed)) return { kind: "ref", value: trimmed }; - return { kind: "raw", description: trimmed }; -} - -function parseErrorsSpec(value) { - // Split on commas, but ignore commas inside parentheses so descriptions - // like "400=update failed (bad hash, write error)" stay intact. - const parts = []; - let depth = 0; - let cur = ""; - for (const ch of value) { - if (ch === "(") depth++; - else if (ch === ")") depth--; - if (ch === "," && depth === 0) { - parts.push(cur); - cur = ""; - } else { - cur += ch; - } - } - if (cur) parts.push(cur); - - return parts - .map((s) => s.trim()) - .filter(Boolean) - .map((part) => { - const m = part.match(/^(\d{3})\s*=\s*(.+)$/); - if (!m) { - warn(`Malformed error spec: ${part}`); - return null; - } - return { code: Number(m[1]), description: m[2].trim() }; - }) - .filter(Boolean); -} - -// -- TypeScript types extraction -------------------------------------------- - -function parseTypes() { - const program = ts.createProgram([tsPath], { - target: ts.ScriptTarget.ES2020, - module: ts.ModuleKind.ESNext, - strict: true, - noEmit: true, - }); - const checker = program.getTypeChecker(); - const sourceFile = program.getSourceFile(tsPath); - if (!sourceFile) throw new Error(`Could not load ${tsPath}`); - - const interfaces = {}; - ts.forEachChild(sourceFile, (node) => { - if (ts.isInterfaceDeclaration(node)) { - interfaces[node.name.text] = describeInterface(node, checker); - } else if (ts.isTypeAliasDeclaration(node)) { - interfaces[node.name.text] = describeTypeAlias(node); - } - }); - return interfaces; -} - -function describeInterface(node, checker) { - const description = jsDocText(node); - const properties = node.members - .filter(ts.isPropertySignature) - .map((member) => describeProperty(member, checker)) - .filter(Boolean); - return { kind: "object", description, properties }; -} - -function describeTypeAlias(node) { - return { kind: "alias", description: jsDocText(node), text: node.type.getText() }; -} - -function describeProperty(member, checker) { - const name = member.name.getText(); - const optional = !!member.questionToken; - const description = jsDocText(member); - const schema = typeNodeToSchema(member.type, checker); - return { name, optional, description, schema }; -} - -function typeNodeToSchema(typeNode, checker) { - if (!typeNode) return { type: "string" }; - - switch (typeNode.kind) { - case ts.SyntaxKind.StringKeyword: - return { type: "string" }; - case ts.SyntaxKind.NumberKeyword: - return { type: "number" }; - case ts.SyntaxKind.BooleanKeyword: - return { type: "boolean" }; - case ts.SyntaxKind.NullKeyword: - return { type: "null" }; - } - - if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) { - return { type: "string", const: typeNode.literal.text }; - } - - if (ts.isUnionTypeNode(typeNode)) { - const parts = typeNode.types.map((t) => typeNodeToSchema(t, checker)); - const stringLiterals = parts.filter((p) => p.type === "string" && p.const !== undefined); - const hasNull = parts.some((p) => p.type === "null"); - const nonNull = parts.filter((p) => p.type !== "null"); - if (stringLiterals.length === parts.length) { - return { type: "string", enum: stringLiterals.map((p) => p.const) }; - } - if (nonNull.length === 1) { - return { ...nonNull[0], nullable: hasNull }; - } - return { oneOf: parts }; - } - - if (ts.isArrayTypeNode(typeNode)) { - return { type: "array", items: typeNodeToSchema(typeNode.elementType, checker) }; - } - - if (ts.isTupleTypeNode(typeNode)) { - return { - type: "array", - prefixItems: typeNode.elements.map((e) => typeNodeToSchema(ts.isNamedTupleMember(e) ? e.type : e, checker)), - }; - } - - if (ts.isTypeReferenceNode(typeNode)) { - return { ref: typeNode.typeName.getText() }; - } - - return { type: "string" }; -} - -function jsDocText(node) { - const docs = ts.getJSDocCommentsAndTags(node); - if (!docs.length) return null; - const parts = docs - .map((d) => { - if (typeof d.comment === "string") return d.comment; - if (Array.isArray(d.comment)) return d.comment.map((c) => (typeof c === "string" ? c : c.text)).join(""); - return ""; - }) - .filter(Boolean); - const text = parts.join(" ").trim(); - return text || null; -} - -// -- OpenAPI builder --------------------------------------------------------- - -function buildOpenApi(routes, types) { - const tagsUsed = new Set(); - for (const r of routes) if (r.tag) tagsUsed.add(r.tag); - - const openapi = { - openapi: "3.0.3", - info: { - title: "OpenNeato API", - version: VERSION, - 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: [...tagsUsed].map((name) => ({ name, description: TAG_DEFAULTS[name] || "" })), - paths: {}, - components: { - schemas: buildSchemas(types, routes), - responses: { - Ok: { - description: "Success acknowledgement", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Ok" }, - }, - }, - }, - }, - }, - }; - - for (const route of routes) { - if (!openapi.paths[route.path]) openapi.paths[route.path] = {}; - const op = {}; - if (route.tag) op.tags = [route.tag]; - if (route.summary) op.summary = route.summary; - const params = buildParameters(route); - if (params.length) op.parameters = params; - const body = buildRequestBody(route); - if (body) op.requestBody = body; - op.responses = buildResponses(route); - openapi.paths[route.path][route.method.toLowerCase()] = op; - } - - return openapi; -} - -function buildSchemas(types, routes) { - const referenced = collectReferenced(routes, types); - const schemas = { - Ok: { - type: "object", - properties: { ok: { type: "boolean" } }, - required: ["ok"], - }, - Error: { - type: "object", - properties: { error: { type: "string" } }, - required: ["error"], - }, - }; - for (const name of referenced) { - const def = types[name]; - if (!def || def.kind !== "object") continue; - schemas[name] = objectToSchema(def); - } - return schemas; -} - -function collectReferenced(routes, types) { - const out = new Set(); - const visit = (n) => { - if (out.has(n)) return; - out.add(n); - const def = types[n]; - if (!def || def.kind !== "object") return; - for (const prop of def.properties) walkSchema(prop.schema, visit); - }; - for (const route of routes) { - if (route.body?.kind === "ref") visit(route.body.ref); - for (const r of route.responses || []) { - if (r.kind === "ref") visit(r.value); - if (r.kind === "array") visit(r.ref); - } - } - return out; -} - -function walkSchema(node, visit) { - if (!node) return; - if (node.ref) visit(node.ref); - if (node.items) walkSchema(node.items, visit); - if (node.prefixItems) for (const i of node.prefixItems) walkSchema(i, visit); - if (node.oneOf) for (const o of node.oneOf) walkSchema(o, visit); -} - -function objectToSchema(def) { - const properties = {}; - const required = []; - for (const prop of def.properties) { - properties[prop.name] = propertyToSchema(prop); - if (!prop.optional) required.push(prop.name); - } - const out = { type: "object", properties }; - if (def.description) out.description = def.description; - if (required.length) out.required = required; - return out; -} - -function propertyToSchema(prop) { - const schema = schemaShape(prop.schema); - if (prop.description) schema.description = prop.description; - return schema; -} - -function schemaShape(node) { - if (!node) return { type: "string" }; - if (node.ref) return { $ref: `#/components/schemas/${node.ref}` }; - if (node.oneOf) return { oneOf: node.oneOf.map(schemaShape) }; - if (node.type === "array") { - const out = { type: "array" }; - if (node.items) out.items = schemaShape(node.items); - // OpenAPI 3.0 doesn't support prefixItems; collapse to items of unknown - // type for tuple-shaped schemas. (Only used for MapCoverageCell which - // isn't part of any HTTP response in practice.) - if (node.prefixItems) out.items = {}; - return out; - } - const out = { type: node.type }; - if (node.const !== undefined) out.enum = [node.const]; - if (node.enum) out.enum = node.enum; - if (node.nullable) out.nullable = true; - return out; -} - -function buildParameters(route) { - const params = []; - for (const p of route.pathParams) { - params.push({ - name: p.name, - in: "path", - required: true, - description: p.notes || undefined, - schema: paramSchema(p), - }); - } - for (const q of route.queries) { - params.push({ - name: q.name, - in: "query", - required: !!q.required, - description: q.notes || undefined, - schema: paramSchema(q), - }); - } - return params.map(stripUndefined); -} - -function paramSchema(p) { - const out = { type: p.type }; - if (p.enum) out.enum = p.enum; - if (p.minimum !== undefined) out.minimum = p.minimum; - if (p.maximum !== undefined) out.maximum = p.maximum; - return out; -} - -function buildRequestBody(route) { - if (!route.body) return null; - if (route.body.kind === "ref") { - return { - required: true, - content: { - "application/json": { - schema: { $ref: `#/components/schemas/${route.body.ref}` }, - }, - }, - }; - } - if (route.body.kind === "multipart") { - return { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - properties: { file: { type: "string", format: "binary" } }, - required: ["file"], - }, - }, - }, - }; - } - return null; -} - -function buildResponses(route) { - const out = {}; - const responses = route.responses || []; - if (responses.length === 0) { - out["200"] = { $ref: "#/components/responses/Ok" }; - } else { - for (const r of responses) { - if (r.kind === "ok") { - out["200"] = { $ref: "#/components/responses/Ok" }; - } else if (r.kind === "ref") { - out["200"] = jsonResponse({ $ref: `#/components/schemas/${r.value}` }); - } else if (r.kind === "array") { - out["200"] = jsonResponse({ - type: "array", - items: { $ref: `#/components/schemas/${r.ref}` }, - }); - } else if (r.kind === "raw") { - const ct = r.description.split(/\s+/)[0]; - out["200"] = { - description: r.description, - content: { [ct]: { schema: { type: "string" } } }, - }; - } - } - } - for (const e of route.errors) { - out[String(e.code)] = { - description: e.description, - content: { - "application/json": { schema: { $ref: "#/components/schemas/Error" } }, - }, - }; - } - return out; -} - -function jsonResponse(schema) { - return { - description: "OK", - content: { "application/json": { schema } }, - }; -} - -function stripUndefined(obj) { - if (Array.isArray(obj)) return obj.map(stripUndefined); - if (obj && typeof obj === "object") { - const out = {}; - for (const [k, v] of Object.entries(obj)) { - if (v === undefined) continue; - out[k] = stripUndefined(v); - } - return out; - } - return obj; -} - -// -- Markdown renderer (operates on the validated OpenAPI document) --------- - -function renderMarkdown(spec) { - const lines = []; - lines.push(`# ${spec.info.title}`); - lines.push(""); - lines.push(`Version: \`${spec.info.version}\``); - lines.push(""); - if (spec.info.description) lines.push(spec.info.description); - lines.push(""); - lines.push("## Base URL"); - lines.push(""); - for (const s of spec.servers || []) { - lines.push(`- \`${s.url}\` ${s.description ? `- ${s.description}` : ""}`); - } - lines.push(""); - - // Group operations by tag, preserving the order tags appear in the spec. - const byTag = new Map(); - for (const tag of spec.tags || []) byTag.set(tag.name, []); - byTag.set("Other", []); - - for (const [pathKey, pathItem] of Object.entries(spec.paths)) { - for (const method of ["get", "post", "put", "delete", "patch"]) { - const op = pathItem[method]; - if (!op) continue; - const tag = op.tags?.[0] || "Other"; - if (!byTag.has(tag)) byTag.set(tag, []); - byTag.get(tag).push({ method: method.toUpperCase(), path: pathKey, op }); - } - } - - lines.push("## Endpoints"); - lines.push(""); - - for (const [tagName, ops] of byTag) { - if (ops.length === 0) continue; - const tagDef = (spec.tags || []).find((t) => t.name === tagName); - lines.push(`### ${tagName}`); - if (tagDef?.description) lines.push(tagDef.description); - lines.push(""); - for (const { method, path: routePath, op } of ops) { - renderOperation(method, routePath, op, spec, lines); - } - } - - lines.push("## Schemas"); - lines.push(""); - const schemas = spec.components?.schemas || {}; - const schemaNames = Object.keys(schemas).sort(); - for (const name of schemaNames) { - renderSchema(name, schemas[name], lines); - } - - return lines.join("\n"); -} - -function renderOperation(method, routePath, op, spec, lines) { - lines.push(`#### \`${method} ${routePath}\``); - lines.push(""); - if (op.summary) { - lines.push(op.summary); - lines.push(""); - } - if (op.parameters?.length) { - lines.push("**Parameters**"); - lines.push(""); - lines.push("| Name | In | Type | Required | Description |"); - lines.push("|------|----|------|----------|-------------|"); - for (const p of op.parameters) { - lines.push( - `| \`${p.name}\` | ${p.in} | ${formatParamSchema(p.schema)} | ${p.required ? "yes" : "no"} | ${escapeMd(p.description || "")} |`, - ); - } - lines.push(""); - } - if (op.requestBody) { - lines.push("**Request body**"); - lines.push(""); - const content = op.requestBody.content || {}; - const ct = Object.keys(content)[0]; - if (ct === "application/json") { - const ref = content[ct]?.schema?.$ref; - if (ref) { - const name = ref.split("/").pop(); - lines.push(`JSON object matching [\`${name}\`](#${name.toLowerCase()}).`); - } else { - lines.push("JSON body."); - } - } else if (ct === "multipart/form-data") { - lines.push("`multipart/form-data` with a `file` field containing the upload payload."); - } else if (ct) { - lines.push(`Content type \`${ct}\`.`); - } - lines.push(""); - } - lines.push("**Responses**"); - lines.push(""); - lines.push("| Status | Description | Body |"); - lines.push("|--------|-------------|------|"); - for (const [status, resp] of Object.entries(op.responses || {})) { - const resolved = resolveResponse(resp, spec); - const body = describeResponseBody(resolved); - lines.push(`| ${status} | ${escapeMd(resolved.description || "")} | ${body} |`); - } - lines.push(""); -} - -function resolveResponse(resp, spec) { - if (!resp.$ref) return resp; - const name = resp.$ref.split("/").pop(); - return spec.components?.responses?.[name] || { description: "" }; -} - -function describeResponseBody(resp) { - if (!resp.content) return ""; - const ct = Object.keys(resp.content)[0]; - if (!ct) return ""; - const schema = resp.content[ct]?.schema; - if (!schema) return `\`${ct}\``; - if (schema.$ref) { - const name = schema.$ref.split("/").pop(); - return `[\`${name}\`](#${name.toLowerCase()})`; - } - if (schema.type === "array" && schema.items?.$ref) { - const name = schema.items.$ref.split("/").pop(); - return `array of [\`${name}\`](#${name.toLowerCase()})`; - } - if (schema.type === "object" && schema.properties) { - const fields = Object.keys(schema.properties) - .map((k) => `\`${k}\``) - .join(", "); - return `object: ${fields}`; - } - return `\`${ct}\``; -} - -function renderSchema(name, schema, lines) { - lines.push(`### \`${name}\``); - if (schema.description) { - lines.push(""); - lines.push(schema.description); - } - lines.push(""); - if (schema.type === "object" && schema.properties) { - const required = new Set(schema.required || []); - lines.push("| Field | Type | Required | Description |"); - lines.push("|-------|------|----------|-------------|"); - for (const [field, fs] of Object.entries(schema.properties)) { - lines.push( - `| \`${field}\` | ${formatPropSchema(fs)} | ${required.has(field) ? "yes" : "no"} | ${escapeMd(fs.description || "")} |`, - ); - } - } - lines.push(""); -} - -function formatParamSchema(schema) { - if (!schema) return "string"; - if (schema.enum) return `${schema.type} (\`${schema.enum.join("\\|")}\`)`; - let out = schema.type || "string"; - if (schema.minimum !== undefined || schema.maximum !== undefined) { - out += ` (${schema.minimum ?? "-∞"}..${schema.maximum ?? "∞"})`; - } - return out; -} - -function formatPropSchema(schema) { - if (!schema) return "any"; - if (schema.$ref) { - const n = schema.$ref.split("/").pop(); - return `[\`${n}\`](#${n.toLowerCase()})`; - } - if (schema.type === "array") { - if (schema.items?.$ref) { - const n = schema.items.$ref.split("/").pop(); - return `array of [\`${n}\`](#${n.toLowerCase()})`; - } - if (schema.items?.type) return `array of ${schema.items.type}`; - return "array"; - } - if (schema.enum) return `${schema.type} (\`${schema.enum.join("\\|")}\`)`; - if (schema.oneOf) return schema.oneOf.map(formatPropSchema).join(" \\| "); - let out = schema.type || "any"; - if (schema.nullable) out += " \\| null"; - return out; -} - -function escapeMd(s) { - return String(s).replace(/\|/g, "\\|"); -} - -// -- Helpers ----------------------------------------------------------------- - -function readFirmwareVersion() { - try { - const ini = fs.readFileSync(path.join(repoRoot, "platformio.ini"), "utf8"); - const m = ini.match(/FIRMWARE_VERSION\s*=\s*"?([^"\n\s]+)"?/); - if (m) return m[1]; - } catch {} - return "0.0.0-dev"; -} - -function warn(msg) { - process.stderr.write(`[gen_api_docs] WARN: ${msg}\n`); -} - -// -- Main -------------------------------------------------------------------- - -function main() { - fs.mkdirSync(outDir, { recursive: true }); - - const routes = parseCpp(); - const types = parseTypes(); - - const openapi = buildOpenApi(routes, types); - fs.writeFileSync(path.join(outDir, "openapi.json"), `${JSON.stringify(openapi, null, 2)}\n`); - - const markdown = renderMarkdown(openapi); - fs.writeFileSync(path.join(outDir, "api-reference.md"), markdown); - - process.stdout.write( - `[gen_api_docs] Wrote ${routes.length} routes, ${Object.keys(types).length} types -> ${path.relative( - repoRoot, - outDir, - )}\n`, - ); -} - -try { - main(); -} catch (err) { - process.stderr.write(`[gen_api_docs] ${err.stack || err.message}\n`); - process.exit(1); -} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 555f427..57ca141 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,352 +1,28 @@ -export interface VersionData { - /** Robot model name (e.g. "Botvac D7") */ - modelName: string; - /** Robot serial number */ - serialNumber: string; - /** Robot firmware version, format "Major.Minor.Build" */ - softwareVersion: string; - /** LIDAR firmware version */ - ldsVersion: string; - /** LIDAR serial number */ - ldsSerial: string; - /** Main board hardware version */ - mainBoardVersion: string; -} - -export interface MotorData { - /** Main brush rotation speed in RPM */ - brushRPM: number; - /** Main brush current draw in mA */ - brushMA: number; - /** Vacuum motor speed in RPM */ - vacuumRPM: number; - /** Vacuum motor current draw in mA */ - vacuumMA: number; - /** Left wheel rotation speed in RPM */ - leftWheelRPM: number; - /** Left wheel load percentage */ - leftWheelLoad: number; - /** Left wheel position in millimeters since boot */ - leftWheelPositionMM: number; - /** Left wheel speed in mm/s */ - leftWheelSpeed: number; - /** Right wheel rotation speed in RPM */ - rightWheelRPM: number; - /** Right wheel load percentage */ - rightWheelLoad: number; - /** Right wheel position in millimeters since boot */ - rightWheelPositionMM: number; - /** Right wheel speed in mm/s */ - rightWheelSpeed: number; - /** Side brush current draw in mA */ - sideBrushMA: number; - /** Laser turret rotation speed in RPM */ - laserRPM: number; -} - -export interface StateData { - /** UI state machine value, e.g. "UIMGR_STATE_STANDBY" */ - uiState: string; - /** Robot state machine value, e.g. "ST_C_Standby" */ - robotState: string; -} - -export interface ChargerData { - /** Battery charge percentage (0-100, -1 = unknown) */ - fuelPercent: number; - /** Battery temperature exceeds safe limits */ - batteryOverTemp: boolean; - /** Charger currently delivering current */ - chargingActive: boolean; - /** Charging circuit enabled (independent of active flow) */ - chargingEnabled: boolean; - /** Fuel gauge reading is trusted */ - confidOnFuel: boolean; - /** Battery has dropped into reserve range */ - onReservedFuel: boolean; - /** Battery considered empty (robot will not start) */ - emptyFuel: boolean; - /** Battery hardware fault detected */ - batteryFailure: boolean; - /** External power supply (dock or barrel jack) connected */ - extPwrPresent: boolean; - /** Battery voltage in volts */ - vBattV: number; - /** External supply voltage in volts */ - vExtV: number; - /** Cumulative mAh delivered into the battery */ - chargerMAH: number; - /** Cumulative mAh discharged from the battery */ - dischargeMAH: number; -} - -export interface ErrorData { - /** True if the robot is currently reporting an error or warning */ - hasError: boolean; - /** "error" for codes 243+, "warning" for codes 201-242 */ - kind: "error" | "warning"; - /** Numeric error/warning code (200 = no error) */ - errorCode: number; - /** Full raw response from the robot (for diagnostics) */ - errorMessage: string; - /** Human-readable message suitable for UI and notifications */ - displayMessage: string; -} - -export interface SystemData { - /** Free heap memory in bytes */ - heap: number; - /** Total heap memory in bytes */ - heapTotal: number; - /** Milliseconds since boot */ - uptime: number; - /** WiFi signal strength in dBm (negative, closer to 0 = stronger) */ - rssi: number; - /** SPIFFS bytes used */ - fsUsed: number; - /** SPIFFS total capacity in bytes */ - fsTotal: number; - /** NTP has successfully synced at least once */ - ntpSynced: boolean; - /** Current Unix epoch time in seconds (UTC) */ - time: number; - /** Time source: "ntp", "robot", or "boot" */ - timeSource: string; - /** Configured IANA timezone (e.g. "America/New_York") */ - tz: string; - /** DST-aware local time, e.g. "Sat 17:45:01" */ - localTime: string; - /** True when daylight saving time is active */ - isDst: boolean; -} - -// Per-day schedule fields (Mon=0..Sun=6), two slots per day. -// Slot 0 (primary): sched{0-6}Hour, sched{0-6}Min, sched{0-6}On -// Slot 1 (secondary): sched{0-6}Slot1Hour, sched{0-6}Slot1Min, sched{0-6}Slot1On -export interface SettingsData { - /** IANA timezone identifier (e.g. "America/New_York") */ - tz: string; - /** 0=off, 1=info, 2=debug */ - logLevel: number; - /** When on, logs go to UDP syslog instead of flash */ - syslogEnabled: boolean; - /** IPv4 address of syslog receiver */ - syslogIp: string; - /** WiFi TX power in 0.25 dBm units (e.g. 34 = 8.5 dBm) */ - wifiTxPower: number; - /** GPIO pin used for UART TX to the robot */ - uartTxPin: number; - /** GPIO pin used for UART RX from the robot */ - uartRxPin: number; - /** Read-only - max valid GPIO for this chip (21 for C3, 39 for ESP32) */ - maxGpioPin: number; - /** mDNS hostname (e.g. "openneato") */ - hostname: string; - /** Navigation mode for house cleaning: "Normal", "Gentle", "Deep", "Quick" */ - navMode: string; - /** Wheel load % for stall detection (30-80) */ - stallThreshold: number; - /** Main brush RPM (500-1600) */ - brushRpm: number; - /** Vacuum speed % (40-100) */ - vacuumSpeed: number; - /** Side brush power in mW (500-1500) */ - sideBrushPower: number; - /** ntfy.sh topic for push notifications (empty = disabled) */ - ntfyTopic: string; - /** Global switch - must be on for any notification to fire */ - ntfyEnabled: boolean; - /** Notify when cleaning completes */ - ntfyOnDone: boolean; - /** Notify on robot error (UI_ERROR_*, code 243+) */ - ntfyOnError: boolean; - /** Notify on robot alert (UI_ALERT_*, code 201-242) */ - ntfyOnAlert: boolean; - /** Notify when robot returns to base */ - ntfyOnDocking: boolean; - /** Master switch for the weekly cleaning schedule */ - scheduleEnabled: boolean; - // Slot 0 (primary) - sched0Hour: number; - sched0Min: number; - sched0On: boolean; - sched1Hour: number; - sched1Min: number; - sched1On: boolean; - sched2Hour: number; - sched2Min: number; - sched2On: boolean; - sched3Hour: number; - sched3Min: number; - sched3On: boolean; - sched4Hour: number; - sched4Min: number; - sched4On: boolean; - sched5Hour: number; - sched5Min: number; - sched5On: boolean; - sched6Hour: number; - sched6Min: number; - sched6On: boolean; - // Slot 1 (secondary) - sched0Slot1Hour: number; - sched0Slot1Min: number; - sched0Slot1On: boolean; - sched1Slot1Hour: number; - sched1Slot1Min: number; - sched1Slot1On: boolean; - sched2Slot1Hour: number; - sched2Slot1Min: number; - sched2Slot1On: boolean; - sched3Slot1Hour: number; - sched3Slot1Min: number; - sched3Slot1On: boolean; - sched4Slot1Hour: number; - sched4Slot1Min: number; - sched4Slot1On: boolean; - sched5Slot1Hour: number; - sched5Slot1Min: number; - sched5Slot1On: boolean; - sched6Slot1Hour: number; - sched6Slot1Min: number; - sched6Slot1On: boolean; -} - -export interface UserSettingsData { - /** Play a click sound on button presses */ - buttonClick: boolean; - /** Play melodies (start, finish, etc.) */ - melodies: boolean; - /** Play warning chimes */ - warnings: boolean; - /** Reduced power cleaning (longer runtime, lower suction) */ - ecoMode: boolean; - /** Maximum power cleaning */ - intenseClean: boolean; - /** Stop and warn when dirt bin is full */ - binFullDetect: boolean; - /** Enable wall following along walls and edges */ - wallEnable: boolean; - /** Robot WiFi radio enabled (separate from bridge WiFi) */ - wifi: boolean; - /** Dim status LEDs at night */ - stealthLed: boolean; - /** Filter change reminder interval in seconds */ - filterChange: number; - /** Brush change reminder interval in seconds */ - brushChange: number; - /** Dirt bin reminder interval in minutes */ - dirtBin: number; -} - -export interface LidarPoint { - /** Bearing in degrees (0-359, 0 = robot front) */ - angle: number; - /** Distance to target in millimeters (0 if invalid) */ - dist: number; - /** Return signal strength (0-255) */ - intensity: number; - /** Per-point error code (0 = valid) */ - error: number; -} - -export interface LidarScan { - /** LIDAR turret rotation speed in Hz */ - rotationSpeed: number; - /** Count of points with error == 0 */ - validPoints: number; - /** Always 360 entries indexed by angle */ - points: LidarPoint[]; -} - -export interface FirmwareVersion { - /** Bridge firmware semantic version */ - version: string; - /** ESP32 chip model (e.g. "ESP32-C3") */ - chip: string; - /** Robot model name (e.g. "Botvac D7", empty until identified) */ - model: string; - /** mDNS hostname (e.g. "openneato") */ - hostname: string; - /** True if the connected robot model is officially supported */ - supported: boolean; - /** True while the bridge is still probing the robot model */ - identifying: boolean; -} - -export interface ManualStatus { - /** Manual mode currently engaged */ - active: boolean; - /** Main brush motor enabled */ - brush: boolean; - /** Vacuum motor enabled */ - vacuum: boolean; - /** Side brush motor enabled */ - sideBrush: boolean; - /** Robot wheel-drop sensor reports the robot is off the ground */ - lifted: boolean; - /** Front-left bumper contacted */ - bumperFrontLeft: boolean; - /** Front-right bumper contacted */ - bumperFrontRight: boolean; - /** Left side bumper contacted */ - bumperSideLeft: boolean; - /** Right side bumper contacted */ - bumperSideRight: boolean; - /** Front wheel stall detected */ - stallFront: boolean; - /** Rear wheel stall detected */ - stallRear: boolean; -} - -export interface LogFileInfo { - /** File name as stored on flash (may include .hs compression suffix) */ - name: string; - /** File size in bytes */ - size: number; - /** True if stored with heatshrink compression */ - compressed: boolean; -} - -export interface MapSession { - /** Always "session" - identifies the record type */ - type: "session"; - /** Cleaning mode: "House", "Spot", or "Manual" */ - mode: string; - /** Unix epoch seconds when the session started */ - time: number; - /** Battery percentage at session start */ - battery: number; -} - -export interface MapSummary { - /** Always "summary" - identifies the record type */ - type: "summary"; - /** Unix epoch seconds when the session ended */ - time: number; - /** Session duration in seconds */ - duration: number; - /** Cleaning mode: "House", "Spot", or "Manual" */ - mode: string; - /** Number of times the robot returned to base to recharge */ - recharges: number; - /** Number of pose snapshots captured */ - snapshots: number; - /** Total distance traveled in meters */ - distanceTraveled: number; - /** Maximum distance from origin in meters */ - maxDistFromOrigin: number; - /** Total rotation in degrees */ - totalRotation: number; - /** Estimated area covered in square meters */ - areaCovered: number; - /** Number of errors encountered during cleaning */ - errorsDuringClean: number; - /** Battery percentage at session start */ - batteryStart?: number; - /** Battery percentage at session end */ - batteryEnd?: number; -} +// HTTP API types are generated from frontend/api/openapi.yaml. Edit the spec, +// not this file. The build pipeline runs `openapi-typescript` to refresh +// types.generated.ts before tsc. +export type { + ChargerData, + ErrorData, + FirmwareVersion, + HistoryFileInfo, + LidarPoint, + LidarScan, + LogFileInfo, + ManualStatus, + MapSession, + MapSummary, + MotorData, + SettingsData, + StateData, + SystemData, + UserSettingsData, + VersionData, +} from "./types.generated"; + +// -- Frontend-only types (not part of the HTTP API) -------------------------- +// These describe shapes used internally by the map view and aren't exchanged +// over the wire. export interface MapPathPoint { x: number; @@ -386,26 +62,11 @@ export interface MapTransform { } export interface MapData { - session: MapSession | null; - summary: MapSummary | null; + session: import("./types.generated").MapSession | null; + summary: import("./types.generated").MapSummary | null; path: MapPathPoint[]; coverage: MapCoverageCell[]; recharges: MapRechargePoint[]; bounds: MapBounds | null; cellSize: number; } - -export interface HistoryFileInfo { - /** File name as stored on flash (may include .hs compression suffix) */ - name: string; - /** File size in bytes */ - size: number; - /** True if stored with heatshrink compression */ - compressed: boolean; - /** True if this session is currently being recorded */ - recording: boolean; - /** Session metadata, or null if not yet written */ - session: MapSession | null; - /** Session summary, or null if cleaning has not finished */ - summary: MapSummary | null; -} From be2790f4d51b8ba35b81beb43e536e77b088d255 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= Date: Mon, 27 Apr 2026 09:31:40 +0300 Subject: [PATCH 09/44] ci: allow legacy peer deps for openapi-typescript on TS6 --- frontend/.npmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/.npmrc 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 From 1356b144bd167aaa8166a1b7f50a1cdf5eb22dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soner=20K=C3=B6ksal?= <15015690+renjfk@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:13:58 +0300 Subject: [PATCH 10/44] feat: fallback WiFi access point for browser-based provisioning (#110) --- AGENTS.md | 3 + docs/user-guide.md | 54 +++- firmware/src/config.h | 12 + firmware/src/main.cpp | 69 +++-- firmware/src/settings_manager.cpp | 15 + firmware/src/settings_manager.h | 11 + firmware/src/web_server.cpp | 26 +- firmware/src/web_server.h | 5 +- firmware/src/wifi_manager.cpp | 247 +++++++++++++++- firmware/src/wifi_manager.h | 79 ++++- frontend/api/openapi.yaml | 138 +++++++++ frontend/mock/server.js | 82 ++++++ frontend/src/api.ts | 8 + frontend/src/assets/icons/globe.svg | 5 + frontend/src/components/confirm-dialog.tsx | 54 +++- frontend/src/style.css | 235 +++++++-------- frontend/src/types.ts | 5 +- frontend/src/views/settings.tsx | 22 +- frontend/src/views/settings/constants.ts | 1 + .../src/views/settings/settings-category.tsx | 23 +- .../src/views/settings/use-settings-form.ts | 8 + frontend/src/views/settings/wifi-section.tsx | 278 ++++++++++++++++++ 22 files changed, 1204 insertions(+), 176 deletions(-) create mode 100644 frontend/src/assets/icons/globe.svg create mode 100644 frontend/src/views/settings/wifi-section.tsx diff --git a/AGENTS.md b/AGENTS.md index 5eee504..e0ba1dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,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/docs/user-guide.md b/docs/user-guide.md index c221939..374e0d7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -15,10 +15,12 @@ Everything you need to set up, configure, and troubleshoot OpenNeato. - [Command Reference](#command-reference) - [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) @@ -232,23 +234,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. -### Serial Monitor +### 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. + +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 @@ -309,6 +336,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 diff --git a/firmware/src/config.h b/firmware/src/config.h index a9f2637..365fbbf 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -110,6 +110,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/main.cpp b/firmware/src/main.cpp index 5d8e0dc..cc80dc8 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -30,7 +30,7 @@ ManualCleanManager manualClean(neatoSerial); NotificationManager notifMgr(neatoSerial, settingsManager, dataLogger); CleaningHistory cleaningHistory(neatoSerial, dataLogger, systemManager); 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/settings_manager.cpp b/firmware/src/settings_manager.cpp index 1c879a1..832c045 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, ""); @@ -93,6 +94,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); @@ -250,6 +252,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; @@ -343,6 +353,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}, @@ -416,6 +427,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 54d3ff8..45338a1 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 @@ -81,12 +86,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/web_server.cpp b/firmware/src/web_server.cpp index 19d07c0..17411e6 100644 --- a/firmware/src/web_server.cpp +++ b/firmware/src/web_server.cpp @@ -8,15 +8,16 @@ #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) : + NotificationManager& notif, CleaningHistory& history, WiFiManager& wifi) : server(server), neato(neato), logger(logger), sysMgr(sys), fwMgr(fw), settingsMgr(settings), manualMgr(manual), - notifMgr(notif), historyMgr(history) {} + notifMgr(notif), historyMgr(history), wifiMgr(wifi) {} void WebServer::loggedRoute(const char *path, WebRequestMethodComposite httpMethod, SyncHandler handler) { server.on(path, httpMethod, [this, handler](AsyncWebServerRequest *request) { @@ -71,6 +72,7 @@ void WebServer::begin() { registerSettingsRoutes(); registerFirmwareRoutes(); registerMapRoutes(); + registerWiFiRoutes(); LOG("WEB", "Frontend and API routes registered"); } @@ -505,3 +507,23 @@ void WebServer::registerMapRoutes() { 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 b8a3e9f..56312ea 100644 --- a/firmware/src/wifi_manager.cpp +++ b/firmware/src/wifi_manager.cpp @@ -3,6 +3,44 @@ #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") {} @@ -29,6 +67,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", @@ -40,6 +79,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() { @@ -48,7 +90,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(); }); @@ -94,6 +136,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') { @@ -106,7 +152,7 @@ void WiFiManager::handleSerialInput() { } } -void WiFiManager::scanNetworks() { +void WiFiManager::scanNetworksMenu() { menu.printStatus("Scanning WiFi networks..."); scannedNetworkCount = WiFi.scanNetworks(); @@ -246,6 +292,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 @@ -273,7 +325,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. @@ -300,8 +354,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. @@ -322,7 +378,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) @@ -365,11 +430,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}, @@ -414,6 +481,180 @@ 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}}); +} + +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::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..f8497fe 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,18 @@ 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(); + // Menu actions - void scanNetworks(); + void scanNetworksMenu(); void manualSSID(); void showStatus(); void resetCredentials(); diff --git a/frontend/api/openapi.yaml b/frontend/api/openapi.yaml index 2b0c3a2..a462a30 100644 --- a/frontend/api/openapi.yaml +++ b/frontend/api/openapi.yaml @@ -18,6 +18,8 @@ tags: 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 @@ -533,6 +535,70 @@ paths: 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: @@ -1177,6 +1243,9 @@ components: 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 @@ -1321,6 +1390,7 @@ components: required: - tz - logLevel + - apFallbackOnDisconnect - syslogEnabled - syslogIp - wifiTxPower @@ -1520,6 +1590,74 @@ components: - 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 diff --git a/frontend/mock/server.js b/frontend/mock/server.js index f2c57d0..28bf171 100644 --- a/frontend/mock/server.js +++ b/frontend/mock/server.js @@ -105,6 +105,15 @@ const _randf = (min, max, decimals = 2) => parseFloat((Math.random() * (max - mi // fp — All polling faults (state + charger + error) // fhc — History corruption (inject corrupted pose lines in session data) // fhl — History list corruption (malformed JSON in /api/history response, triggers recovery panel) +// +// WiFi: +// wap , Fallback AP active (STA disconnected, fallback enabled) +// wnc , No saved credentials (first boot, AP always on) +// wfo , Fallback AP setting off (combine with wap to test off + 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 +// // fal — All faults combined const SCENARIO = "ok"; @@ -166,6 +175,12 @@ const SCENARIOS = { 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, @@ -177,6 +192,8 @@ const SCENARIOS = { pollError: true, historyCorrupt: true, historyListCorrupt: true, + wifiScan: true, + wifiConnect: true, }, }, }; @@ -202,6 +219,9 @@ const faults = { pollCharger: false, pollError: false, historyCorrupt: false, + wifiScan: false, + wifiScanEmpty: false, + wifiConnect: false, ...(merged.faults || {}), }; @@ -246,6 +266,12 @@ const state = { lidarSlowRotation: false, tz: "UTC0", logLevel: 0, + apFallbackOnDisconnect: true, + // WiFi mock state , `wifiDisconnected` flips STA to disconnected and + // brings up the fallback AP; `wifiNoCredentials` clears saved SSID so + // the UI shows the "first boot" path (no current network, no forget). + wifiDisconnected: false, + wifiNoCredentials: false, syslogEnabled: false, syslogIp: "", wifiTxPower: 60, // 15 dBm in 0.25 dBm units @@ -743,6 +769,7 @@ const routes = { const keys = [ "tz", "logLevel", + "apFallbackOnDisconnect", "syslogEnabled", "syslogIp", "wifiTxPower", @@ -775,6 +802,59 @@ const routes = { jsonResponse(res, s); }, + "GET /api/wifi/status": (_req, res) => { + // No saved credentials: STA never reports a "last SSID" and the + // fallback AP is always up regardless of the toggle. + const hasCreds = !state.wifiNoCredentials; + const apOn = !!state.wifiDisconnected || !hasCreds; + jsonResponse(res, { + 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" : "", + }); + }, + + "GET /api/wifi/scan": async (_req, res) => { + // Simulate scan latency + await new Promise((r) => setTimeout(r, rand(800, 1500))); + if (faults.wifiScan) return sendError(res, "WiFi scan failed: radio busy", 500); + if (faults.wifiScanEmpty) return jsonResponse(res, { networks: [] }); + jsonResponse(res, { + 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 }, + ], + }); + }, + + "POST /api/wifi/connect": async (_req, res, query) => { + if (!query.ssid) return sendError(res, "missing ssid", 400); + await new Promise((r) => setTimeout(r, rand(500, 1500))); + if (faults.wifiConnect) { + return sendError(res, "wrong password or authentication rejected", 500); + } + // Simulate reboot path on success , caller will see the AP go away + state.wifiDisconnected = false; + state.wifiNoCredentials = false; + sendOk(res); + }, + + "POST /api/wifi/disconnect": (_req, res) => { + state.wifiDisconnected = true; + state.wifiNoCredentials = true; + sendOk(res); + }, + "POST /api/notifications/test": (_req, res, query) => { if (!query.topic) return sendError(res, "missing topic", 400); sendOk(res); @@ -1075,6 +1155,7 @@ const handleRequest = async (req, res) => { 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.apFallbackOnDisconnect !== undefined) state.apFallbackOnDisconnect = data.apFallbackOnDisconnect; if (data.syslogEnabled !== undefined) state.syslogEnabled = data.syslogEnabled; if (data.syslogIp !== undefined) state.syslogIp = data.syslogIp; if (data.wifiTxPower !== undefined) state.wifiTxPower = data.wifiTxPower; @@ -1115,6 +1196,7 @@ const handleRequest = async (req, res) => { const keys = [ "tz", "logLevel", + "apFallbackOnDisconnect", "syslogEnabled", "syslogIp", "wifiTxPower", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 25cd70a..f85a0e3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,6 +12,8 @@ import type { StateData, SystemData, UserSettingsData, + WiFiScanResult, + WiFiStatus, } from "./types"; async function parseError(res: Response): Promise { @@ -184,4 +186,10 @@ export const api = { 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/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/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