Repository navigation
Dev Web App
The web app is a SvelteKit 2 single-page PWA (apps/web) built on the design system libs/mowdeck-ui. In production the web service serves it at /, on the same origin as the API (/api/v1/*). Decisions: ADR 0020 (architecture) and ADR 0021 (authentication in the browser). Design and the visual direction: Design: web app (section 14 lists what differs in the implementation, Tasks 10 to 13, 17, 18 and 21). The package READMEs (apps/web/README.md, libs/mowdeck-ui/README.md) are the reference for commands and files.
The frontend is a pnpm workspace next to the uv workspace. The root holds only configuration and scripts.
| Path | Package | Content |
|---|---|---|
apps/web |
web (AGPL-3.0) |
The SvelteKit app, its messages, service worker, nginx configuration and Dockerfile |
libs/mowdeck-ui |
@mowdeck/ui (Apache-2.0) |
Design tokens, Svelte 5 components, the lint rules of the design system and the contrast check |
tools/ts-codegen |
ts-codegen |
Generates the TypeScript API types (own TypeScript 5.9 for openapi-typescript) |
tests/e2e-web |
e2e-web |
Playwright: the Phase 2 and Phase 3 gates, desktop checks and review screenshots |
Root files: pnpm-workspace.yaml, package.json (packageManager: pnpm@10.34.6, shared lint tooling), .npmrc (engine-strict, no automatic peers, exact versions), .node-version (22), .prettierrc.json, .prettierignore, eslint.config.js, stylelint.config.js. The root pyproject.toml excludes libs/mowdeck-ui and tools/ts-codegen from the uv workspace.
Requirements: Node 22 (22.12 or newer), pnpm 10.34.6 (corepack enable or npm install -g pnpm@10.34.6), and for the dev loop the Compose stack (Docker).
The app talks to the real services. Start the Compose stack first, then the Vite dev server:
cp infra/compose/.env.example infra/compose/.env # once; set the passwords
docker compose -f infra/compose/docker-compose.yml up -d --build --wait
pnpm install
pnpm dev # http://localhost:5173pnpm dev compiles the messages and starts Vite on port 5173. Vite proxies /api (including the Realtime WebSocket) to MOWDECK_DEV_GATEWAY (default http://localhost, the stack's Traefik), so the app and the API share one origin exactly as behind Traefik; the proxy rewrites the socket's Origin to the gateway's, which Realtime's allowed origins accept. The built app is also served by the stack itself at http://localhost (service web); rebuild it with docker compose up -d --build web.
Register an account at /register (the stack's IDENTITY_REGISTRATION=open), register a mower with connection kind simulated and start it on the detail page; MOWDECK_SIM_TIME_SCALE=30 makes a lawn take minutes.
Checks, from the repository root (CI runs the same in the job web):
pnpm install --frozen-lockfile
pnpm -r lint # ESLint, Prettier check, Stylelint, i18n check
pnpm -r check # Paraglide compile, svelte-kit sync, svelte-check
pnpm -r --filter '!e2e-web' test # Vitest (coverage thresholds), contrast check, codegen check
pnpm --filter ts-codegen check # generated types are up to date
pnpm --filter web build # static build in apps/web/build, plus build/csp.conf
pnpm format # Prettier write and ESLint fixA plain pnpm -r test also runs the Playwright suite of tests/e2e-web, which needs the running stack (see Testing).
Never edit apps/web/src/lib/generated/. The types come from committed sources in two steps:
-
uv run poe openapi(scripttools/repo-checks/scripts/export_openapi.py) builds every service in memory and writesapps/web/openapi/<service>.json(sorted keys).uv run poe openapi-checkand the repository testtools/repo-checks/tests/test_openapi_export.pyfail when a committed document is stale. -
pnpm codegen(tools/ts-codegen/gen.mjs) writesidentity.ts,fleet.ts,telemetry.ts,device-gateway.ts,notifications.ts,scheduler.tswithopenapi-typescriptandrealtime.tsfrom the contracts'RealtimeServerMessageandRealtimeClientMessageJSON Schemas withjson-schema-to-typescript.pnpm --filter ts-codegen checkfails on drift.
After changing a service route or a contract: run uv run poe openapi (and the contracts' schema export if a model changed), then pnpm codegen, and commit everything together.
The clients in src/lib/api/client.ts are openapi-fetch clients, one per service, sharing one fetch wrapper: bearer token, X-Correlation-ID, one retry after a refresh on 401 (see Session and live data), and a 30 s deadline per request (token wait included) after which it fails as a network problem, so no page loads forever. Every non-2xx application/problem+json response becomes an ApiProblem (src/lib/api/problem.ts); pages show a translated message keyed by the problem type and the correlation id.
The shell (src/routes/(app)/+layout.svelte, src/lib/shell/registry.svelte.ts) builds the navigation from built-in modules. A module is a file src/lib/modules/<name>/module.ts:
import { m } from '$lib/paraglide/messages';
import type { AppModule } from '../types';
export default {
id: 'mowers',
route: '/mowers',
label: () => m.nav_mowers(),
icon: 'mower',
requiredPermission: 'mower.view',
order: 10,
} satisfies AppModule;A module is shown when the user holds requiredPermission (from me.permissions) and either it is core or a deployed service announces its route as a ui_area (GET /api/v1/identity/ui-areas). If that endpoint fails, the permission alone decides; the menu never blocks the emergency stop. A ui_area without a built-in module is only logged.
To add a module:
- Add the route under
src/routes/(app)/<route>/+page.svelte(pages stay thin; reusable parts go tolibs/mowdeck-uiorsrc/lib/components). - Add
src/lib/modules/<name>/module.ts(the registry finds it withimport.meta.glob), the label message in bothmessages/*.jsonand, if needed, an icon inIcon.svelteofmowdeck-ui. - Announce the route in the backing service's module descriptor (
ui_areaswithroute,menu_label, optionallabelsandrequired_permission), unless the module iscore. - Check per-object rights on the page with
POST /api/v1/identity/me/permissions(cached per mower for 60 s insrc/lib/live.svelte.ts);me.permissionsis only an upper bound. The server checks again in every case.
The phone bar holds five targets; with more modules the first four stay and the rest open from a "more" sheet above the bar (NavRail with moreLabel; the order in module.ts decides). Navigation entries: mowers (10), fleet (12), schedules (15), notifications (20), reports (40), settings (90) and, for operator admins only, operator (95). Fleet announces /fleet, Telemetry /reports (both follow mower.view). A module with platformAdmin: true (the operator console) is shown only to operator admins of the installation: no permission key or token claim says so, so the shell asks GET /api/v1/identity/me/platform once per session (src/lib/operator/access.svelte.ts; {"operator_admin": true} shows it; false, a 404 from an older Identity or any failure hides it quietly, nothing is logged; logout forgets the answer). ui_areas without a built-in module (such as /settings/audit, served inside another page) are logged at debug level only.
-
One settings page. Modules with
placement: 'settings'are not navigation entries but sections of/settings(src/routes/(app)/settings/+page.svelte, components insrc/lib/settings/sections/), in module order: account and profile (#profile, 5), notifications (#notifications, 21), users (#users, 50), AI provider (#ai-provider, 55), linked accounts (#linked-accounts, 58), API tokens (#api-tokens, 60). Each section follows the same visibility rule as a module (permission,coreor ui_area);areanames the ui_area when it differs from the route (the notification settings follow/notifications). A numbered section index sits on the left on the desktop and as a sticky strip under the top bar on phones, and marks the section in view. -
Old routes forward.
/account,/notifications/settings,/settings/users,/settings/ai-provider,/settings/linked-accountsand/settings/api-tokensrenderSettingsForward, which replaces the address with/settings?<query>#<section>(forwardTargetinsrc/lib/settings/sections.ts; an anchor of the old link wins, so Stripe's/account?billing=success#planlands on the plan). New links point at the anchors directly; a user who must change the password lands on/settings?password=1#profile. -
Viewport-bound shell on the desktop. From 1024 px
AppShellis exactly100dvhhigh withoverflow: hidden: the document never scrolls, the content area (main) is the only scroll container, and pages with their own scroll boxes (the mower detail's instrument column, the fleet map) fill it. The rail keeps the brand pinned at the top and scrolls its items internally when the window is too low; its tiles are 60 px (icon and a short uppercase label), so eight entries fit in about 560 px.
Paraglide JS 2: messages/de.json is the base locale, messages/en.json the second one; project.inlang/settings.json loads the message format plugin from jsDelivr when the messages are compiled (the compiled output in src/lib/paraglide/ is generated, never edited). Locale strategy: localStorage, then the browser languages, then German.
- No literal text in markup: the ESLint rule
mowdeck/no-literal-text(inlibs/mowdeck-ui/lint/) reports text nodes andaria-label,title,placeholderandaltvalues with letters inapps/web/src/**/*.svelte(units such asmm,m/s,%are allowed). Usem.some_key(). - Keys are
snake_casewith a prefix for the screen or concern:nav_,cmd_,activity_,rtk_,problem_,detail_,mower_,users_,tokens_,account_, ... - Enum values (activity, RTK fix, command kind, problem type) are translated by exhaustive functions in
src/lib/i18n.ts, so a new contract value fails the type check. - Numbers, dates and durations use
Intlwith the active locale (decimal comma in German); units are not translated. -
pnpm --filter web i18n:check(part oflint) fails when the two files differ in keys or{parameters}, or a text is empty. German texts are longer: check layouts in German. - Services answer with English
detail; the app shows its own text for the problem type and falls back todetail.
The visual direction is the "instrument panel" (design section 12). Rules:
-
Tokens only. Colours, font stacks and radii come from
libs/mowdeck-ui/src/lib/styles/tokens.css(var(--md-...)); the Stylelint rulemowdeck/use-tokensrejects raw values everywhere else. Spacing uses the 4 px grid tokens, motion the--md-motion-*tokens, touch targets at least 48 px (--md-target48 px,--md-target-command56 px,--md-target-stop64 px). -
Colour means state. Every state has a word and a glyph besides its colour; the stop red is reserved for
EmergencyStop. -
Components come from
@mowdeck/ui(table in its README). They take texts as props and never fetch. The command bar always renders the emergency stop; sheets stop above the command bar (--md-sheet-bottom). "Zur Station" while the mower is mowing, paused or in error opens the choice sheet (dock-choice):dock-choice-endsendsreturn_to_dockwithend_job: true(the job ends, primary button),dock-choice-keepwithend_job: false(the job stays paused on the mower); at the dock or idle the plain command goes out without the sheet. The recent commands list shows "Zur Station (Aufgabe beendet)" forend_job(CommandView.end_job). -
Themes: System, Light, Dark.
static/theme-init.jsapplies the stored choice (localStoragekeymowdeck.theme) before the first paint;tokens.cssswitches withprefers-color-schemeunlessdata-themeoverrides it. -
pnpm --filter @mowdeck/ui tokens:checkfails when a token pair misses its contrast target (part of the library'stest).
UI checklist for every pull request that touches apps/web or libs/mowdeck-ui (attach light and dark screenshots at phone and desktop width, see Testing):
- Only tokens used; no new colours, radii or fonts outside
tokens.css - Colour is used only for state; every state also has text or a glyph
- Readouts in Plex Mono with units; labels uppercase 12 px
- Targets at least 48 px, gaps at least 8 px; checked at 390 px width
- Emergency stop visible and reachable on every mower view, including with sheets and keyboard open
- Light and dark screenshots attached; sunlight check (light theme, full brightness) for new status UI
- No hard-coded strings; German and English texts present and fitting (German is longer)
- Pending, error, empty, offline and no-permission states designed
- Keyboard focus visible,
prefers-reduced-motionrespected
The detail page shows the mower on MowerMap from @mowdeck/ui (Leaflet 1.9.4, loaded lazily); ADR 0023 records the decisions.
-
Data. The map snapshot comes from
GET /api/v1/telemetry/mowers/{id}/map(local metres plusGeoReference); the page keeps the ETag and revalidates once a minute withIf-None-Match(304 keeps the map). The mower comes from the live state store, the driven route fromGET /api/v1/telemetry/mowers/{id}/positions?from=...&limit=5000for the window chosen in the route panel (1 h, today, 24 h; remembered per browser inlocalStorage) plus every newer live position (src/lib/mower/route.ts). -
Projection modes (
libs/mowdeck-ui/src/lib/map/projection.ts): without tiles the map uses Leaflet'sCRS.Simplein the mower's local metres (a Leaflet[lat, lng]is[y, x]) over a 1 m and 5 m grid, with a scale bar in metres and a north arrow rotated by the reference rotation; with tiles it usesEPSG:3857and converts local metres to WGS84 with the TypeScript port ofmowdeck_control.geo(geo.ts). Overlay code never knows the mode: it asks the adapter for map coordinates and screen rotations. -
Heat layers. A strip under the map (
HeatmapControls, testidheatmap-controls) switches one heat layer on: Aus, Abdeckung (coverage: where the blades cut), RTK (fixed share), WLAN and Mobilfunk (median signal), with a period (letzter Auftrag, 24 h, 7 Tage default, 30 Tage), a cell size (1, 2 or 5 m), "Aktualisieren", the legend of the active scale and the state of the last load (loading, "noch keine Daten in diesem Zeitraum" for an empty answer, "no map yet" for 404no-map-yet, an error with the translated problem, the number of samples, "coarsened to N m" when the answer'scell_mis bigger than requested, "partly evaluated" fortruncated). The data comes fromGET /api/v1/telemetry/mowers/{id}/heatmap?layer=coverage|rtk|wifi|mnet&from=&to=&cell=(src/lib/map/heatmap.ts: local types of the answer until the generated types include the endpoint, the request with the shared authenticated fetch, the period windows, the per-browser choice inlocalStoragekeymowdeck.heatmap). "Letzter Auftrag" runs from the neweststart_mowamong the mower's recent Fleet commands to the nextreturn_to_dock(or now); without a start in the list it shows the last 24 h and says so. Cells (x,yinteger indexes, cell lower-left atx * cell_m,y * cell_mlocal metres) are drawn byMowerMap(heatprop) as rectangles in their own pane below the areas, so the area outlines stay crisp. Colour scales (libs/mowdeck-ui/src/lib/map/heat.ts, CSS expressions over the tokens, so both themes work without a redraw; cells at half opacity): coverage in five steps from--md-color-ink-muted(not cut) to--md-state-mowing(always cut; the design system has no separate accent, and "cut" is the mowing state); RTK in three bands of the fixed share, below 50 %--md-rtk-none, 50 to 90 %--md-rtk-float, from 90 %--md-rtk-fixed; WLAN and Mobilfunk in five 10 dB steps from -90 to -40 dBm,--md-state-errorthrough--md-state-pausedto--md-state-mowing, labelled "schwach" ... "stark". The map element carriesdata-heat-cells,data-heat-kindanddata-heat-gap. -
Missed spots. With the coverage layer,
HeatmapGaps(testidheatmap-gaps) lists the answer'sgapsunder the strip, largest first with their area in m², and the map outlines every gap thinly (dashed ink). Selecting a row (heatmap-gap-<index>) highlights the spot (highlightGapofMowerMap: focus-blue outline and fill) and brings it into view. The selected row offers "Nachmähen" (heatmap-gap-remow): when the viewer may drive and the mower drives manually (canSpot), it opens spot mode with the gap's polygon as the spot (spotFlow.fromGap, see "Spot mowing"); otherwise it stays disabled with its reason as a tooltip and as visible text; "Bereich mähen: " (heatmap-gap-mow-area, only for users who may start now and when the spot's centre lies in a mapped area) opens the area start picker with that area chosen; "Markierung aufheben" clears the highlight. -
Blade height and speed at the start. The start sheet's "Mähen" section has steppers for Schnitthöhe (
start-blade, the model'sblade_height_range_mm, step 5 mm) and Tempo (start-speed,min_speed_mps..max_speed_mps, step 0.1 m/s), pre-filled with the reported blade height and speed (when idle the adapter reports the speed setpoint). "Starten" sendsset_blade_heightand/orset_speedfirst, only for values that changed (src/lib/mower/setpoints.ts,setpointCommands), one after the other, thenstart_mow; the start button shows one pending state ("Wird gesendet …") and the sheet stays open when a command could not be sent. They are mower setpoints the mower keeps, not part of the mow defaults; the page's own steppers stay. Without the model's ranges the section shows the current values and "Anpassen" (jump to the page's steppers) as before. -
Mow path. The route the mower planned (
MowPath: polylines in local metres,kinds,points_total,progress.index= points done over all polylines) is drawn byMowerMap(pathprop,libs/mowdeck-ui/src/lib/map/mow-path.ts) as a thin line in the accent colour (--md-color-focus, so it stays apart from the green driven route) in its own pane below the route: the part up toprogress.indexsolid, the part ahead dimmed (splitPathProgresscuts the active polyline so both parts meet), transfers dotted, with a legend (mow-path-legend: "Mähpfad", "Gemäht", "Geplant", and the caption "Vorschau" withpathPreview). The map element carriesdata-path-points,data-path-doneanddata-path-preview. The detail page (src/lib/mower/mow-path.ts) pollsGET /api/v1/telemetry/mowers/{id}/pathevery 10 s while the activity is mowing, paused or returning (404no-pathuntil the gateway has the route) and keeps the last job's path on the map while the job still exists on the mower (paused, or returning after a plain "Nur zur Station"). The path goes when the gateway publishespath: null(or 404) and the mower is docked, charging or idle (the poll continues at rest while a job's path is held), when areturn_to_dockwithend_jobwas sent ("Zur Station, Aufgabe abbrechen"; the ended job's path hash is not shown again if the gateway still answers it), and when a start or a preview begins (reconcilePath,commandClearsPathinsrc/lib/mower/mow-path.ts); the toggle "Mähpfad" (mow-path-toggle, in the heat strip,localStoragekeymowdeck.mowPath, on by default) hides it, and the strip shows the progress ("42 % · 230 von 548 Punkten") or that the path follows. In the start sheet "Pfad berechnen" (start-preview-path) asks Fleet (POST /api/v1/fleet/mowers/{id}/path-previewwith the sheet's areas and pattern,fleetSlowApi, 90 s) to let the mower plan without starting;start-preview-statussays what happened (planning, "Vorschau · N Punkte", outdated after the areas or pattern changed, a timeout, unsupported, the problem) and the map shows the preview while the sheet is open (also with the toggle off: it was asked for). "Starten" sends the same areas and pattern; the preview stays on the map until the job's own path arrives. -
RTK summary. The detail page's RTK panel keeps its own time range (today, 7 days default, 30 days;
localStoragekeymowdeck.rtkRange) loaded from/positionsin pages of 5000 (Telemetry's row cap) going back withtoset to the oldest point, at most 4 pages (20 000 points,src/lib/mower/rtk-range.ts), and shows the shares of fixed, float and none as a bar and readouts; its switch "Auf der Karte zeigen" selects the RTK heat layer. The older client-side RTK signal map (MowerMap'srtkPoints,showRtk,rtkCellSize,rtkAggregate, binned bylibs/mowdeck-ui/src/lib/map/rtk.ts) is deprecated; it stays in the layer panel only for callers that do not passheat. -
Layer model.
layers.tsturns snapshot, state and route into descriptors (areas with label position and start order, no-go zones, obstacles, paths, dock, mower with tone and rotation, route segments by age and optional RTK class, grid, bounds). It is pure and unit tested; the component only draws it. Styles are CSS classes on Leaflet's SVG paths and on our own marker elements, so light and dark need no redraw. -
Area names (
areaLabel,areaNamerandfleetAreaNamerinsrc/lib/mower/map-labels.ts, used by the mower page, its area list and picker, the map labels, the schematicAreaPlan, the drive page, the schedule form and the fleet map): the custom name from Fleet (MowerOut.area_names), else the mower's own name unless it is empty or only a long number (a vendor hash such as4525245168006937683), else "Bereich N" / "Area N" with N the position in the map's area order; the same rule asmowdeck_contracts.v1.area_display_name.areaNamer(map.areas, mower.area_names)gives the map components theirareaNamefunction; without an indexareaLabelkeeps the old "Area 7683" fallback. The mower's stored names stay as they are. -
Renaming (users with
can_control; viewers see the names): a pencil button per row of the area list and of the start picker (area-rename-open,data-rename-area) opens the inline form, and the edit button beside each map label (area-rename-map,onarealabelofMowerMap) opens the rename sheet (area-rename-sheet; inline in the picker while it is open). The form (AreaRenameForm:area-rename-form,area-rename-input,area-rename-hint,area-rename-save,area-rename-reset,area-rename-cancel) starts with the current custom or own name, shows the mower's own name as a hint (or the numbered fallback when the mower has none), saves withPUT /fleet/mowers/{id}/area-names/{area_id}and offers "Zurücksetzen" while a custom name exists. A blank field or the mower's own name drops the custom name; more than 60 characters are refused before sending (src/lib/mower/area-names.ts). -
Selection. Users with
can_controltap areas on the map whenever Start is possible; the taps build the ordered selection, Start opens the picker sheet with that selection already chosen (it can still change it there), and the sheet sendsstart_mow {area_ids, pattern}in that order. Areas are keyboard reachable (focusable paths witharia-pressed). -
Pattern settings at the start (
MowSettings.svelte, logic insrc/lib/mower/mow-pattern.ts, testidstart-settings). Under the area list the start sheet shows the section "Mähen" / "Mowing": the pattern as a radio group of glyph tiles (start-pattern,start-pattern-<name>witharia-checked; only the names ofcapabilities.native_patterns, in the order Streifen, Diagonal, Schachbrett, Nur Rand, Zufallswinkel; the stripe glyphs turn with the angle), the angle in 15 degree steps from 0 to 180 (start-angle, hidden foredge_onlyandrandom_angle; diagonal adds 45 degrees on the mower, which a hint says), the border laps 0 to 4 (start-border-laps; at least 1 for edge only), the path spacing in 1 cm steps withincapabilities.path_spacing_range_mm(start-spacing, only when the model reports a range; "Modell" until changed, which keeps the mower's own spacing), the summary line (start-summary, e.g. "Streifen 45° · 1 Randrunde", plus "· 30 cm Bahnabstand" when a spacing is chosen), the current blade height and speed with "Anpassen" (start-setpoints,start-setpoints-adjust: closes the sheet and scrolls to the steppers; both stay separate commands) and the switch "Als Standard merken" (start-remember, on by default). The sheet opens with the mower'sMowerOut.mow_defaults.pattern, else stripes at 0 degrees with one border lap; a saved pattern the mower cannot run becomes stripes (or its first native pattern), values are clamped. Start sendsstart_mow {area_ids, pattern: {name, angle_deg, border_laps, spacing_m?}}; after a start that went out, with the switch on and settings that differ from those the sheet opened with, it saves them withPUT /fleet/mowers/{id}/mow-defaults(a failure shows a toast, the start stands). Without native patterns (or capabilities not loaded yet) the section gives way to a note (start-no-patterns) and the start sends no pattern, as before. The recent commands show the summary after "Mähen starten" fromCommandView.pattern(commandLabel(kind, endJob, pattern),patternSummaryin$lib/i18n). The schedule form keeps its own angle and rotation list; it has no pattern or border laps, so it does not share this component yet (the Scheduler applies the mower's saved defaults and sets only the angle). -
Start sheet layout. The sheet is short on the common path and its start row is pinned:
Sheethas afooterthat stays at the bottom while the body scrolls, holding "Pfad berechnen" (start-preview-path) with its status (start-preview,start-preview-status) and "Starten" (area-start). From 1024 px the sheet iswide(760 px, above the command bar) with two columns: the area list with order badges on the left, "Mähen" (MowSettings) on the right, always open. On phones it is one column and the settings are folded: the summary line (start-summary, e.g. "Diagonal 0° · 3 Randrunden · 50 mm · 0,3 m/s" style) stays visible with "Anpassen" / "Weniger" (start-settings-toggle,aria-expanded); expanded it shows the pattern tiles (compact grid,auto-fillfrom 4.5rem, so 3 to 5 per row), then angle, border laps and spacing as compact steppers in one row where they fit (auto-fit, 10.5rem), then blade height and speed. Those two reuseStepperwithimmediate(commit at every step, the draft goes out with the start) in a grid ofauto-fit, minmax(220px, 1fr): each keeps its range caption, they stack on narrow widths, and the stepper shrinks with its container so the "+" is never clipped. The gate spec opens the settings with one click onstart-settings-togglewhen it is visible (phone). -
Start sheet flow (
src/lib/mower/start-sheet.svelte.ts,StartSheetFlow, unit tested instart-sheet.test.ts). The page keeps only markup and wiring: the flow class holds the picked areas, the pattern and setpoint drafts the sheet opens with, the preview (one at a time, keyed by areas and pattern, with the timeout/unsupported/problem message), the rain warning with its extra click, the start order (changed setpoints one after the other, thenstart_mow, then the mow defaults when they differ from what the sheet opened with) and the dock choice. ItsStartSheetDepsport takes the API calls (preview, setpoint, start, dock, save defaults), the map's path hooks, a clock and acontext()getter for what the page knows (areas, offered patterns, ranges, reported setpoints, rain bars); the pure rules (previewFailure,defaultsChanged,jobMinutes,rainStatusOf,jobActive) sit insrc/lib/mower/start-sheet.ts. - Phones. The map sits between the readouts and the controls; the expand control shows it full screen above everything except the command bar, so the emergency stop stays visible (Escape or the control closes it). Desktop: the map fills the centre next to the instrument column.
-
Fallback. When Leaflet fails to load, the page shows the schematic
AreaPlan(SVG) and a short note; without a map snapshot it shows the empty grid with a hint. -
Golden vectors. After a change to
libs/mowdeck-control/src/mowdeck_control/geo.py, regenerate the fixture:uv run python libs/mowdeck-ui/scripts/geo_vectors.py libs/mowdeck-ui/src/tests/fixtures/geo-vectors.json;src/tests/geo.test.tsrequires agreement within 1e-9 degrees.
src/lib/config.ts fetches /config.json once per page load and validates it; anything missing or invalid becomes the typed default { "mapTiles": null, "legalEnabled": false, "cameraEnabled": false } (the grid, no legal links, no third-party video). Shape:
{
"mapTiles": { "url": "https://tile.example.org/{z}/{x}/{y}.png", "attribution": "© Example", "maxZoom": 19 },
"legalEnabled": false,
"cameraEnabled": false
}In the image, nginx/40-mowdeck-runtime-config.sh (an /docker-entrypoint.d hook) renders the file at container start from MOWDECK_WEB_MAP_TILES, MOWDECK_WEB_MAP_ATTRIBUTION, MOWDECK_WEB_MAP_MAX_ZOOM, MOWDECK_WEB_LEGAL_ENABLED and MOWDECK_WEB_CAMERA_ENABLED (Compose: WEB_MAP_TILES, WEB_MAP_ATTRIBUTION, WEB_MAP_MAX_ZOOM, WEB_LEGAL_ENABLED, WEB_CAMERA_ENABLED) into /tmp/mowdeck/config.json and writes the CSP with the tile origin added to img-src (a {s} subdomain becomes https://*.host) and, with the camera enabled, the Agora domains (see Live camera). legalEnabled shows the links to the legal pages on the sign-in page. A template that is not http(s) with {z}, {x} and {y} is logged and ignored. The service worker never caches /config.json; pnpm dev has no such file and uses the default.
Privacy. Tiles are off by default. With tiles, every map view tells the tile server which tiles are shown, which is the location of the garden, plus the viewer's IP address. The public OpenStreetMap servers have a usage policy that does not fit an app (https://operations.osmfoundation.org/policies/tiles/); use your own or a commercial provider and show the attribution it requires (openstreetmap.org URLs get "© OpenStreetMap contributors" automatically). An API key in the template is visible to every user.
The module notifications (bell icon, mower.view; the Notifications service announces the ui_area /notifications) has two pages: the inbox /notifications and the settings /notifications/settings. Both talk to /api/v1/notifications with notificationsApi (types from openapi/notifications.json). Backend reference: services/notifications/README.md, ADR 0022.
-
Inbox (
src/lib/stores/inbox.svelte.ts, wired insrc/lib/notifications/runtime.ts): the newest 50 items and the unread count. There is no Realtime message for new notifications, so the store pollsGET /inboxevery 60 s while the app is open (the shell starts it for the navigation badge) and refreshes onfocusand when the tab becomes visible. Opening an item marks it read (POST /inbox/{id}/read) and goes to its page; "mark all read" marks every loaded unread item. Theft alarms (mower_lifted,mower_left_dock_unexpectedly,geofence_exit;ALARM_KINDSinkinds.ts) get an acknowledge action under the item (POST /inbox/{id}/ack: read plus acknowledged, the server silences repeats of that alarm for an hour) and then show an "acknowledged" badge with the time; when the alarm link carries the reported position (/mowers/<id>/map?lat=..&lon=..) the item shows the coordinates as a link./mowers/[id]/mapforwards to the mower page, which shows the reported position as a note. Kinds are badges with a word, a glyph and the state tone of what happened (src/lib/notifications/kinds.ts: stuck is the error tone, battery low the warning tone, offline dashed). -
Settings: kinds, all mowers or a chosen set, quiet hours (saved with the device's IANA time zone and the app language), channels the server offers; ntfy target (server, topic, optional token: write-only, omitted keeps the stored token, "delete token" sends an empty string); test notification (202 shows the channels, 429 shows a countdown from
Retry-Afterand disables the button). Every failure shows the problem+json title, detail and correlation id.
-
pushSupport()(src/lib/push/device.ts) decides what the page offers:insecure(no secure context: Web Push needs HTTPS, plain HTTP works only onlocalhost),ios-install(iOS and iPadOS deliver Web Push only to a web app added to the home screen, https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/),unsupported, orsupported. - "Enable on this device" (a user action) runs
PushDevice.enable():Notification.requestPermission(),GET /push/vapid-public-key(404: Web Push is not configured on the server),registration.pushManager.subscribe({userVisibleOnly: true, applicationServerKey})(an existing subscription with the same key is reused, one with another key replaced),POST /push-subscriptionswithtoJSON(). The returned id is kept inlocalStorage(mowdeck.push-subscription) to mark "this device" in the list; the VAPID key goes to the Cache API storemowdeck-pushfor the service worker. - The service worker (
src/service-worker.ts) handlespush(parses theWebPushPayloadwithsrc/lib/push/payload.ts; shows title, body,tagso a repeat of the same kind and mower replaces the earlier notification, and the page indata.url, reduced to a path on this origin; a payload it cannot read still shows a notification, asuserVisibleOnlyrequires),notificationclick(focuses a window already on that page, otherwise sends the open window anavigatemessage so the app routes itself and keeps its in-memory session, otherwise opens a new window) andpushsubscriptionchange(re-subscribes with the stored key). - The worker never calls the API: it has no token (ADR 0021). A renewed subscription waits in
mowdeck-push; the app posts it on its next start or at once when the worker sendspush-renewed(PushDevice.syncPending()), and deletes the replaced one. The shell cache clean-up keepsmowdeck-push.
Deployment notes: browsers allow Web Push only in a secure context, so a home installation needs HTTPS on the gateway (for example Tailscale HTTPS certificates or a domain with Let's Encrypt on Traefik); http://localhost works for development. The push service hosts must be on NOTIFICATIONS_WEBPUSH_ALLOWED_HOSTS. On iPhone and iPad the page explains "Share, Add to Home Screen" and the installed app must be opened from there. Headless Chromium has no push service, so the Playwright suite covers preferences, ntfy, the test notification and the inbox, while the subscription flow is unit tested with a fake PushManager (src/lib/push/push.test.ts); delivery is checked by hand on the phones.
The module schedules (calendar icon, mower.view; the Scheduler announces the ui_area /schedules) talks to /api/v1/scheduler with schedulerApi (types from openapi/scheduler.json). Backend reference: services/scheduler/README.md, ADR 0024.
-
Rights. Every viewer of a mower sees its schedules. Edit controls (enabled switch, skip next, edit, delete, create) appear per target only when
POST /me/permissionsanswerscan_edit_scheduleandcan_controlon it; run now needscan_control. The Scheduler checks again on every call (403 and 404 are shown as problems). A schedule the user may not change opens read-only (summary and preview). -
List
/schedules: per schedule the name, the target (mower or group), the recurrence in words (src/lib/schedules/recurrence.ts: "Mo, Mi, Fr 07:00, 90 min", "Mo–Fr", "Täglich", "Alle 3 Tage ab 12.10.2026"), the next firing in the schedule's time zone with a countdown (and a skipped firing), the rain verdict of the preview as a badge (run,hold,no location,unknown, orrain pause off), the last run's outcome, the enabled switch, skip next, run now (a sheet with "respect the rain pause", default on when the schedule's rain pause is on, which then shows the runs it recorded) and the runs link. Preview (?count=3) and last run (runs?limit=1) are loaded per row. -
Editor
/schedules/new(optional?mower=<id>) and/schedules/[id](ScheduleForminScheduleEditor): target (segmented mower or group, then the select), recurrence (weekday chips with presets, or every N days with the first day), start time, window in minutes and return to dock at its end, time zone override (empty follows the site; "use "), areas of the target mower picked on its map (MowerMapin select mode, or the schematic plan; chips in mowing order; nothing chosen means every area; groups always mow all), mowing angle (pattern.angle, other pattern keys are kept) and the rotation list (add, reorder, remove, presets 0/90 and 0/45/90/135), rain pause (enable, probability %, precipitation mm/h, look ahead h, drying after rain h, mow without a forecast, stop a running job when it rains).src/lib/schedules/form.tsmirrors the Scheduler's rules (validate_scheduleand the request models) on submit and then live, and maps a 422 to the fields (request errors by pointer, the domain's messages by their wording). -
Preview (beside the form on desktops, below it on phones): the rain decision for the next firing as a badge, the forecast hours as a bar strip (
RainStrip: height is the probability, hours that reach a threshold in the paused tone with a drop mark, the firing window underlined, the probability threshold dashed), the location source and provider, and the next 5 firings (skipped ones struck through). It shows the saved state and says so when the form has unsaved changes. A new schedule shows the current decision at the target mower (GET /weather/{mower_id}). -
Runs
/schedules/[id]/runs: newest first, 50 per page (before), with the planned time, trigger (schedule or manual), mower, outcome badge (started green, rain amber, skipped neutral, window closed dashed, mower busy teal, no permission and failed red), the follow-up (docked for rain, window end, finished) and the command id linking to the mower's recent commands (/mowers/<id>#recent-commands). -
Site location (
SiteLocationon the mower settings page): the mower's site with name (the default site keeps its name), time zone and coordinates; "use the map origin" copies the map'sGeoReferenceorigin. It needscan_manage_mowerson the site (read-only otherwise; Fleet decides onPATCH /sites/{id}). Without coordinates the rain pause uses the map origin or the last position. - Mower detail: the panel "Nächster Zeitplan" shows the next enabled schedule that starts the mower (its own or one of its groups') with time, countdown and rain verdict, linking to it (hidden when the Scheduler does not answer).
-
Rain on the mower page: the panel "Regen" (
RainReadout.svelte) shows the current precipitation or "Trocken", the next 12 hours as aRainStripand, when the next schedule has a rain pause that holds, "Regenpause aktiv bis ..." (last wet hour plus cooldown). The page readsGET /api/v1/scheduler/weather/{mower_id}(decision.hours,decision.verdict,decision.holds) on open and every 15 minutes; thresholds come from the next schedule's rain pause, else the defaults (60 %, 0.5 mm). A 404 or verdictno_locationshows "kein Standort" with a link to the mower settings; verdictdisabled(provider off) andunknown_*(provider down) show a short note; a Scheduler that does not answer hides the panel. The strip shows the last 12 past hours dimmed (wet by millimetres only) and a "jetzt" mark;rain-wetsays "Rasen nass, trocken ab ca. HH:MM" or "Rasen trocken" from the decision'swet/wet_until(the Scheduler adds the drying time to the cooldown, see its README); the weather request passes the next schedule's rain-pause settings as query parameters. Helpers:src/lib/mower/rain.ts. -
Rain warning at start: the start sheet shows an estimated duration (
start-duration,src/lib/mower/duration.ts: with a path previewpoints_total * 1 m / speed, elsearea / (cutting width * speed) * 1.3; capped 30 min to 6 h; speed from the draft/mower, cutting width from the capabilities, fallbacks 0.5 m/s and 0.4 m). When it rains now or a wet hour overlaps the job,start-rain-warningnames the hour and probability and "Starten" becomes "Trotzdem starten": one extra click, no hard block. A wet lawn (wet) warns too ("Rasen vermutlich noch nass bis ca. ..."). A changed selection resets the confirmation.
The drive page /mowers/[id]/drive drives a mower with a joystick over the Realtime socket (ADR 0026, protocol in services/realtime/README.md). The mower page offers "drive manually" (joystick icon in the header) when the capabilities say manual_drive and POST /me/permissions answers can_drive; Realtime and Fleet decide again on drive_start and the page shows their refusal (drive_not_allowed, drive_unsupported, drive_busy, drive_unavailable) in words.
-
Safety rules. The Device-Gateway's dead-man timer is the only safety authority; the page only stops sending. Frames carry
linearandangularin -1..1 andblade_on; every frame livesttl_ms(1 s by default), so the mower stops within that time whenever frames cease. The session ends (and frames stop) on "Stop" (a zero frame, thendrive_stop), on the emergency stop (always in the bar at the bottom,StopBar), when the page is left (onDestroy,pagehide), when the tab is hidden (visibilitychange), and when the socket closes (the client reportsconnection_lostand refuses frames). A job that is mowing or returning blocks the start (pause or end it first).drive_endedreasons are toasts in German and English (src/lib/drive/labels.ts). -
Cadence (
src/lib/drive/pilot.ts,DrivePilot). TheJoysticksamples at the server'smax_rate_hzwhile it is held. A deflected stick sends every sample; a held, centred stick sends a zero frame at 2 Hz only (it keeps the session alive); letting go sends one zero frame (forced past the rate limit, blades off) and then nothing. The Realtime client drops frames that come sooner than1 / max_rate_hz(10 % slack) before they reach the socket. Withoutcapabilities.drive_reverse(ADR 0043) the pilot cuts backwards deflections tolinear0 (clampReverse), the pad's lower half is hatched and a note says the link cannot drive backwards (drive-no-reverse). - Speed limit 25 % (default), 50 % or 100 % of the forward and backward range; turning keeps its full range.
-
Blades only when the mower reports
manual_drive_blades: the session is opened withblade_on_allowedthen, and the switch needs two steps (arm, then switch on). Every stop, error and session end disarms; the release frame never carries blades. -
Session strip: state (ready to drive, connecting, driving, locked) as word and lamp, the short session id, frames sent and acknowledged (
drive_frame_ack) in the last second, dead-manttl_ms, the server'smax_rate_hzand the time left ofmax_seconds. -
Screen wake lock (
navigator.wakeLock, where the browser offers it) while a session is active. -
Map: a small
MowerMapwithfollowon and the route of this visit. The virtual fence (no-go zones) is enforced by the simulator and the adapters, not by the page.
The live camera of a cloud mower (ADR 0032). When the capabilities say camera is not none, the mower page shows the camera panel inline: in the side column on the desktop, after the status block (badge and readouts) and before the areas list ("Bereiche"), on phones after the map and before the areas; the setpoint controls and recent commands follow as before. There is no header button. Anybody who sees the mower may watch (Fleet checks can_view and the plan feature camera). The panel is open by default; its collapse toggle (camera-toggle, aria-expanded) is remembered per browser (localStorage mowdeck.cameraOpen). Collapsed means no session: the player unmounts and leaves the channel, expanding requests a new session. "Full screen" (in the panel's head) leads to /mowers/[id]/camera with the state line and the emergency stop in view.
-
Session. Opening the panel calls
POST /api/v1/fleet/mowers/{id}/camera/session. Fleet relays it to the Device-Gateway, which asks the connection: the Mammotion cloud adapter fetches an Agora stream token from Mammotion, the simulator answers afakesession. The response holds a short-lived token (Cache-Control: no-store); the app keeps it in memory only. -
Players (
src/lib/camera).provider: "fake":FakePlayerdraws the test pattern (test-pattern.ts: colour bars, a sweep, the clock, the mower and camera name and its position) on a canvas at 10 frames per second.provider: "agora":agora-player.tsis imported dynamically, soagora-rtc-sdk-ng(4.24.8, about 1.5 MB, 420 KB gzipped) is its own chunk that loads only when a real camera opens. It joins with the channel profile from the options (defaultrtc;livejoins as audience with a latency level), configures the channel encryption first when the session has one (aes-256-gcm2, the base64 salt decoded to bytes), subscribes to the chosen camera's publisher uid (or, without a choice, the first remote user with video); in the side-by-side view it subscribes both front cameras, one subscription per uid, each attached to its own tile's element (aCameraChoiceis a uid, null or a list of uids in tile order;FakePlayerdraws one canvas per tile, the right camera of the pair mirrored). It never callstrack.play(): the SDK's player applies inline styles the CSP refuses (style-srchas no'unsafe-inline'), and so would its autoplay prompt (switched off withonAutoplayFailed). Insteadmedia.tsputs the rawMediaStreamTracks (video and, when published, audio) into the tile's own<video>element (srcObject = new MediaStream([...]),muted,playsinline,play(); the sound goes only to the first tile) and empties it on unsubscribe; the sound button toggles that element'smuted. The SDK's log upload is switched off. -
Diagnose (collapsible line "Diagnose" under the picture, in the panel and on the full-screen page;
diagnostics.ts). While open it reads every 2 sclient.getRemoteVideoStats()[uid]andclient.getRTCStats()and shows, in monospace: receive bitrate (kbit/s), frame rate, resolution, stalls (freezeRateof the last second andtotalFreezeTime), current packet loss (currentPacketLossRate, the last 400 ms; the SDK reports fractions 0 to 1, shown in percent) next to the cumulativepacketLossRatesince the join (lost / (received + lost)), the delay with its source (end2EndDelaywhen above 0, elsereceiveDelay,transportDelayor the RTCRTT;-when none) and RTT, the connection state, the counts ofconnection-state-change,user-publishedanduser-unpublishedevents since the join and the rejoins, and timestamps of the last three events. WithlocalStorage.setItem('mowdeck.cameraDebug', '1')the same snapshot also goes toconsole.debugevery 2 s (even with the line closed). The statistics are read every 2 s whenever a real camera plays, also with the line closed:tracksholds the numbers of every shown camera (getRemoteVideoStats()[uid]per tile), and each tile's caption shows its resolution and frame rate (1280x720 · 15 fps,trackSummary). -
Connection options (collapsible "Erweitert", stored per browser in
localStoragemowdeck.cameraOptions;options.ts): channel profilertc(default) orlive(createClient({ mode });livejoins withsetClientRole('audience', { level }), level 1 low latency or 2 ultra low latency, shown only forlive); stream fallback (default on:client.setStreamFallbackOption(uid, 2), the low-quality stream on a poor network and audio only if that is not enough; off sets0); data saver (default off:client.setRemoteVideoStreamType(uid, 1), always the low stream; only works if the publisher sends two streams, errors are ignored); rejoin automatically (default on). A new profile or latency level joins again, the other two apply at once. -
Rejoin and token.
connection-state-changetoDISCONNECTED(reason notLEAVE) starts a rejoin with a fresh session after 1, 2, 4, 8, 16 s (then 16 s) for at most 6 attempts in a row (reset when the picture is live; a failing session request counts as an attempt);RECONNECTINGfor more than 20 s rejoins at once with a fresh session. Without the option the stream ends with "interrupted".token-privilege-will-expirerequests a new session and passes its token toclient.renewToken(token).user-publishedof a uid after itsuser-unpublishedsubscribes again by itself; "No picture" shows only while the camera publishes nothing. Subscribe events run in a queue so they cannot interleave. -
Viewer (
viewer.svelte.ts,CameraViewer):requesting,connecting,liveorwaiting(joined, no picture yet),error,closed. A fresh session is requested a minute beforeexpires_at(at the latest at 80 % of its lifetime) and when the SDK firestoken-privilege-will-expire; the same channel only renews the token (renewToken), another channel rejoins. Closing the panel, leaving the page (onDestroy,pagehide) or switching mowers leaves the channel. -
Camera picker lists only cameras whose uid publishes video right now (from
client.remoteUserswithhasVideo, updated onuser-published,user-unpublishedanduser-left; the LUBA 2 AWD X has two front cameras and no rear one, although the session lists three slots) and hides with a single publisher. Labels come from the camera'splacement(front_left"Front links",front_right"Front rechts",front,rear), else from well-known ids (Mammotion slots0,1,2; the simulator'sfront_left,front_right); a publisher the session does not list shows as "Camera ". A chosen camera that stops publishing shows "No picture from this camera" instead of switching to another one; a small line shows the active uid. -
Both front cameras (
layout.ts,stereoPairinsession.ts). When the session has afront_leftand afront_rightcamera with two different uids, the picker offers "Beide" / "Both" (also while one of them does not publish, so its tile can say so): left and right side by side, stacked (left above right) when the panel is narrower than 560 px (a container query; the side column and phones stack, the full-screen page sits side by side). The layout is remembered per browser (localStoragemowdeck.cameraLayout, defaultsingle). Every tile (camera-tile,camera-tile-right;data-status,data-side,data-camera-uid) has its own canvas and video, a caption with the side mark, the camera name and its live resolution and frame rate, and a status:live,connecting("Waiting for this camera …"),missing(the uid publishes nothing: "No picture from this camera") orno_data(wanted, but no picture within 8 s,NO_DATA_MS: "Keine Daten von dieser Kamera"). A tile never shows another camera's picture in its place. A guard compares 32 x 18 thumbnails of both tiles every 2 s (sameness.ts); three nearly identical readings in a row show "Beide Ansichten zeigen dasselbe Bild" (camera-same-picture), so the same stream never appears twice unannounced. With real video a note says that two streams use about twice the data. On Mammotion mowers both front cameras publish because the adapter asks the cloud for every camera (all_cameras, see the adapter README). Data note under the picture (the video runs through Mammotion's and Agora's servers, 4G minutes count) and the 4G allowance when Mammotion reports it; the session's expiry and the number of renewals. -
Errors in words: no camera on this connection (409
camera-unsupported), not in the plan (403feature-not-in-plan), not reachable (503, also when the mower is offline), mower not found, not a secure context (WebRTC needs HTTPS orlocalhost), live video switched off on this installation (cameraEnabledfalse), browser without WebRTC, session expired, stream interrupted; with "try again" where it can help. -
CSP and switch. The build's CSP has
connect-src 'self',media-src 'self'andworker-src 'self'. WithMOWDECK_WEB_CAMERA_ENABLED=true(ComposeWEB_CAMERA_ENABLED, Helmconfig.WEB_CAMERA_ENABLED) the container addshttps://*.agora.io wss://*.agora.io https://*.sd-rtn.com wss://*.sd-rtn.comand the same hosts with:*toconnect-src(a host source without a port matches only the scheme's default port, and Agora's edge servers listen on others, e.g.wss://208-81-200-218.edge.agora.io:4718/) andblob:tomedia-srcandworker-src, and the hash'sha256-cPxxYHvCiQ/kT6TlojjBofFPdVQmCg8k/CdPKQDf9zI='tostyle-src(agora-rtc-sdk-ng 4.24.8 sets one inline style itself: a 1 px probe<video style="position: fixed; top: 0; left: 0; width: 1px; height: 1px">on every remote video track, which reads the resolution; without the hash the style is dropped and the console reports a violation; re-check it on SDK updates;src/lib/camera/csp-script.test.tsrenders the CSP with and without the camera), and/config.jsonsays"cameraEnabled": true. Without it anagorasession ends in the "switched off" message; the test pattern of the simulator needs nothing. -
Privacy. Off by default. With the camera enabled, the video of the garden and the viewer's IP address go to Agora's servers (and from the mower through Mammotion's cloud), and the SDK reports call statistics to Agora. Every opened session is in Fleet's audit (
mower.camera_opened: who and when, never the token). Over 4G the stream counts against the mower's video allowance; close the camera when you do not need it. -
Real mower. The owner's check is documented in the adapter README (
scripts/try_real_mower.py --camera); then open the mower in the web app over HTTPS with the switch on. -
Camera light ("Kameralicht", the camera's headlamp;
src/lib/components/LightToggle.svelte, testidslight-controlwithdata-stateon/off/unknownanddata-pending,light-toggle,light-state,light-note). The camera panel shows the mower's light as an instrument tile above the picture, also while the camera is collapsed: a lamp (filled amber with a halo when on, hollow when off, dashed when unknown), the state the mower reports (MowerState.light_on: "An", "Aus", or a dash while null) and arole="switch"button that sendsset_lightwith the opposite of the reported state through the command store. The switch never shows an optimistic state: while the command is pending (commands.pending(id, 'set_light')) the readout turns to the pending style, the note says "Wird geschaltet …" and the button waits; the new state appears when the mower reports it. Disabled, with the reason below, when the capabilities have nolight, while the mower is offline (activityofflineor no state) and withoutcan_control. A mower with a light but no camera gets the tile in the "Steuerung" panel instead. Fleet needs the plan featurescontrolandcameraforset_light. - The service worker precaches the Agora chunk with the rest of the build (once per app version); pages load it only when a real camera opens.
The health page /mowers/[id]/health (pulse icon in the mower page header) shows Telemetry's health history (ADR 0027, services/telemetry/README.md). Pure helpers in src/lib/health/series.ts, unit tested.
-
Blade change interval: hours since the blade change against
blade_hours_thresholdfromGET /healthas a meter with the 80 % mark and a word and glyph (within range, due soon from 80 %, due). Durations under an hour read in minutes, so a young mower does not show "0.00 h". - Totals: blade hours, mowing hours, distance (km from 1 000 m), battery cycles, charge sessions, since and as of.
-
Last 30 days (
/health/daily, UTC days, absent days are zero): blade time and distance as SVG bar strips in tokens (no chart library; the newest day in ink, the peak and the total as readouts). -
Battery of the last 24 hours (
/battery?from&to&step=15m) as an SVG line with the band below 20 % in the error tint, a marker on the newest value, min, max and latest as readouts. -
Errors by code and the blade changes (
/health/blade-changes). "Blades replaced" (POST /health/blade-resetwith an optional note) needscan_manage_mowers(asked withPOST /me/permissionsfirst; a 403 hides it). - The emergency stop is in the bar at the bottom, as on every mower view.
The fleet module (/fleet, icon fleet: a folded map; Fleet announces the ui_area for mower.view) has three sections in a tab strip (FleetTabs): map, sites and groups. Everybody who views mowers sees the mowers and places they may view; the edit and bulk controls appear after POST /me/permissions per site or group (can_manage_mowers, can_bulk_command), and Fleet checks every request again.
-
Fleet map (
/fleet,FleetMapoflibs/mowdeck-ui): positions fromGET /api/v1/telemetry/accounts/me/mowers/statesplus the Realtime subscription of the shown mowers (at most 200). Mowers of several sites have different local frames, so the map always works in WGS84 (geoprojection); without tiles it draws a metric grid around the fleet reference whose step follows the visible span. Each mower is a 48 px marker in its state colour with its name; a tap selects it and shows its card (state, battery, last update, site, open). The map snapshots (/mowers/{id}/map) of the shown mowers load lazily, four at a time, and their areas are drawn faintly. Filters: site and group; the legend counts the mowers per state; mowers without a position are listed beside the map. -
Bulk commands: "pause all" (
pause_mow) and "all to dock" (return_to_dock) for the filter: the group, else the site, else one request per visible site (src/lib/fleet/bulk.ts).BulkSheetlists the affected mowers and sends nothing before the confirm tap; afterwards each line shows Fleet's outcome (accepted,forbidden,not_published), "not sent" when Fleet left the mower out, and a 403 on the target itself marks its mowers "not allowed". The buttons needfleet.bulk_command(an upper bound fromme.permissions; the sites and groups pages ask per object). -
Sites (
/fleet/sites): the default site first and marked (its built-in name "Default site" is shown translated), time zone, address (street, postal code, city, ISO country), coordinates (offered from the map origin of a mower of the site, or of any visible mower for a new site), mowers and groups. Create and edit in a sheet (src/lib/fleet/places.ts:siteForm,validateSite,siteBody); delete answers 409 for the default site and for sites with mowers or groups (shown as a hint). "Move mowers here" lists the mowers of other sites (PATCH /mowers/{id}withsite_id). -
Groups (
/fleet/groups): site groups and cross-site groups with their members (from the mowers'group_ids; Fleet's group list has no members). Create and edit (name, site or across sites), choose members (each toggle isPUT /mowers/{id}/groupswith the mower's whole set), delete (409 while it has mowers), bulk commands per group, and "new schedule" (/schedules/new?group=<id>preselects the group). -
Mower settings show the same placement (
MowerPlacement): move to another site, join or leave groups. -
Staff per site or group (
/settings/users): every assignment shows its role and level ("Bediener · Gruppe Nord") and can be removed; "assign role" opens a sheet with the role and the level (LevelPicker: account, site, group or mower) and calls Identity'sPOST /accounts/{id}/role-assignments({role_id, level: {type, id}, subject_type: "user", subject_id}). Creating a user can assign a role at a level too, or none ("assign later"). Identity checks anti-escalation and that the level belongs to the account. - Mower list: with mowers on more than one site, sections per site (collapsible); from four mowers on, quick filters with counts: all, working (mowing, returning, paused), attention (error, offline activity, no live link, no telemetry), at the dock.
/reports (icon chart; Telemetry announces the ui_area for mower.view) shows, per site or for the whole account and per period (today, 7 days, 30 days, custom up to 366 days; UTC days as Telemetry counts them): mowing time, blade time, distance, jobs, faults and availability as totals, a table per mower with a totals line, the fleet's daily mowing time as a bar strip and faults by code.
-
Data: one call,
GET /api/v1/telemetry/reports/summary?from&to(day totals of every visible mower; Telemetry filters withlist_objects(can_view, mower)). Telemetry does not know sites, so the site filter uses Fleet'ssite_idof each mower; mowers without data in the period appear with zeros. Jobs are transitions into mowing (a resume after a pause continues the job); availability is the online time divided by the elapsed hours of the period (today up to now), shown with one decimal below 10 %. -
Helpers (
src/lib/reports/aggregate.ts, unit tested):periodRange,customRangeIssue,elapsedHours,reportRows,reportTotals,faultsByCode,dailySeries,reportTable. -
CSV (
src/lib/reports/csv.ts, built in the browser): UTF-8 with byte order mark, CRLF line ends, ISO dates; dialect German (semicolon, decimal comma; the default in German) or English (comma, decimal point). Columns with stable English keys whatever the UI language:mower_id;mower;site;from;to;mowing_hours;blade_hours;distance_m;jobs;faults;availability_percent;faults_by_code, one line per mower and atotalline. File namemowdeck-report-<scope>-<from>_<to>.csv. "Copy as table" puts the same rows tab separated on the clipboard (pastes as columns into a spreadsheet).
Every account sees its plan in the section "Plan" of the account page (src/lib/components/PlanPanel.svelte, data from GET /api/v1/entitlements/me, kept in src/lib/plan/plan.svelte.ts and loaded once per session by the shell):
- A plate with the plan's name and key, the status as a badge (active, trial, grace, expired in the state colours) and the end of a trial or grace period, otherwise the start.
- The limits as gauges (
LimitMeter: ten segments, amber from 75 %, red when reached; unlimited and uncounted limits hatched) with the current use where the app already knows the count: mowers, sites (the hidden default site counts, as for Fleet), groups and schedules from the lists the user can see, users from the account's member list when the user may manage users. The use is what the signed-in user can see (exact for owners); the services count for themselves. - The features as chips (
src/lib/plan/labels.tstranslates the known keys; unknown keys show the catalogue's description or the key). - With
billing_enabled: false(the installation default) a calm note says that this installation has no subscription limits; every gauge then shows unlimited. - In
graceorexpireda notice says that only viewing and the emergency stop remain; the same message is a banner on the mower list and the mower page (PlanBanner). No mower is stopped by a plan change, and the stop is never gated.
Refusals: 409 urn:mowdeck:problem:plan-limit carries the limit key with code: limit and code: current in errors (parameter is the key), 403 feature-not-in-plan the feature as the error's parameter and the reason as its code (plan_inactive, entitlements_unavailable). src/lib/plan/problems.ts turns them into words ("Plan limit reached: Mowers", "The plan allows 4, there are 4."; "Not in the plan: Bulk commands"); problemMessage uses them for every inline notice, and the create actions (mower, site, group, user, schedule) also raise a problem toast with notifyPlanProblem.
The app never assumes billing: on the account page it probes GET /api/v1/billing/status once per page load (probeBilling in src/lib/billing/billing.ts, plain fetch, never throws) and only when the user holds billing.manage (the owner, or a member granted it; admins never). The Billing service runs only with the Compose profile billing; without it the gateway answers 404, or the web server's catch-all answers with the SPA's HTML and status 200. A network error, any non-2xx status, a non-JSON answer, invalid JSON or a body without enabled: true all mean "disabled", and then not a single element with a data-testid starting with billing- appears (the Playwright suite checks this with the stack default).
With billing enabled, BillingPanel (data-testid="billing-panel") sits under the plan in the section "Plan": the plans on sale with their name (the key when Entitlements sent none), the price per interval (Intl.NumberFormat in the active locale, amounts in cents), the trial days of a first subscription and a VAT note with Stripe Tax; an Upgrade button per plan other than the current one (billing-upgrade-<key>) while the account has no running subscription (none, canceled or incomplete_expired), which posts POST /checkout {plan} and sends the browser to the returned URL; Manage subscription (billing-portal, POST /portal) once the account is a customer; the subscription's status with renewal, end or trial end; the invoices (number, date, amount, status, links to the hosted page and the PDF in a new tab with rel="noopener noreferrer"; only http(s) URLs become links). 409 on checkout says to use the portal; every other refusal is a ProblemNotice. Stripe returns to /account?billing=success#plan or ?billing=cancel#plan: the page shows a toast, reloads the plan and drops the query parameter; the portal returns to /account#plan. State: src/lib/billing/account.svelte.ts (subscription and invoices load independently, so one failure does not hide the other).
/legal/imprint, /legal/terms and /legal/privacy (src/routes/legal/[page]) fetch /legal/<page>.md (static/legal/, English placeholders with TODO sections for imprint per section 5 DDG, terms and a GDPR privacy policy) and render a tiny Markdown subset (src/lib/legal/markdown.ts: headings, paragraphs, lists, bold, links limited to http(s), mailto, tel and same-origin paths) as plain Svelte text nodes, so HTML in the file is never interpreted and the CSP needs nothing new (connect-src 'self'). Every legal page shows the banner "Placeholder: to be completed by the operator" (legal-placeholder); an unknown page says it does not exist. Operators replace the files by mounting their own over /usr/share/nginx/html/legal/ (nginx serves them no-cache as text/markdown, a missing file is a 404; the service worker does not precache them). The sign-in page links to the three pages only when /config.json has legalEnabled: true (MOWDECK_WEB_LEGAL_ENABLED=true, default false).
AI assist ("KI-Hilfe", ADR 0033) asks a vision model for a short maneuver that frees a stuck mower or brings it towards the dock; nothing drives until a user approves, and the blades are always off. The client is visionApi (src/lib/api/client.ts, generated from openapi/vision.json); the calls live in src/lib/vision/api.ts behind the VisionApi interface so tests hand in a client with a fake fetch.
-
Entry on the mower page (
/mowers/<id>), only withcan_drive: "Recover with AI assist" (assist-open-recover) while the state iserror, "Find dock with AI assist" (assist-open-dock) while the mower is idle or paused and not charging. -
Sheet (
src/lib/components/AssistSheet.svelte, the non-modalSheetabove the command bar, so the emergency stop stays usable): the provider a request would use (GET /me/provider: own model, the installation's model, or "none configured" with a link to/settings/ai-provider) and the note that the blades stay off. Opening it first looks for an open proposal of the mower (GET /mowers/{id}/proposals?limit=1, newest not final) and shows that one; otherwise "Analyse" postsPOST /mowers/{id}/recoverwithtaskand the camera panel's frames (src/lib/vision/snapshot.ts): every live tile's picture carriesdata-camera-video,data-camera-name(the session's camera name),data-camera-side(left/rightin the side-by-side view) anddata-camera-live. With both front cameras live and the user's stereo preference on (GET /me/preferences,stereo_frames, default on) both frames of the same moment go out: the two grabs start together, each video frame is taken in its ownrequestVideoFrameCallbackand timed by its receive time (back to back where the browser has no frame callbacks), and a pair more than 200 ms apart is grabbed once more before only the left frame is sent; each image carriescamera,sideandcaptured_at. Otherwise one JPEG of the first live tile (canvas, at most 1280 px wide, quality 0.8, base64, withcamera). The simulator's test pattern canvas counts as a camera picture. Without a camera no image is sent and the service asks the mower. The ready view says which: both front camera frames (stereo, about twice the image cost), one camera frame, or a photo from the mower. A 409 conflict loads the open proposal; 409vision-provider-missinglinks to the settings; 429vision-quotaand 403feature-not-in-planappear in words. -
Proposal: polled every second while
finalis false (AssistStoreinsrc/lib/vision/assist.svelte.ts, a generation counter drops answers for a closed sheet). It shows the state badge, situation, confidence in percent, the expiry countdown, the analysed snapshot (GET /proposals/{id}/images/0, fetched with the token and shown from a blob URL; a stereo proposal,stereo: true, shows both images side by side captioned "Front links" and "Front rechts" fromframesand a "Stereo" mark with the hint that the model may estimate distances from the parallax, testidsassist-stereoandassist-images; below it "Aufnahme mit Kameralicht" / "Taken with the camera light on" with a lamp and a short explanation whenlight_usedis true, testidassist-light-used: the service switched the mower's light on for a dark photo), the steps in words (stepDescription: linear -1..1 backward to forward, angular -1..1 right to left, a 0.05 deadband, e.g. "Back 1.0 s", "Turn left 1.0 s", "Forward and right 2.0 s"), the risk flags and the rationale. Approve (POST /approve) and Reject (POST /reject, which also cancels a running maneuver) are 48 px buttons; during the execution each step shows its outcome. The outcome says recovered, not recovered (with "Try again",POST /retrywith a fresh snapshot, whilecan_retry), aborted or failed with the service's detail, rejected by the model with its reasons, expired or rejected. -
Deep link: the inbox notification
vision_proposallinks to/mowers/<id>?assist=<proposal id>; the page opens the sheet on that proposal (ignored when it belongs to another mower) and drops the parameter when the sheet closes. -
Settings (
/settings/ai-provider, "KI-Anbieter"; moduleai-provider, shown when Vision announces the ui_area and the user holdsmower.drive): family cards fromGET /providers(choosing one pre-fills its first example and, for families with a base URL,default_api_base; the card says local or cloud and the key variable), the model string, the base URL (required for Ollama and OpenAI compatible servers), the API key as a write-only password field (placeholder "stored" when one is set, "Remove key" sends an empty string, an empty field keeps the stored key,src/lib/vision/provider.ts), the enable switch, Save (PUT /me/provider), Delete and Test (POST /me/provider/test: model, latency, tokens and the estimated cost, or the error detail). Notes say that every analysis is a paid request (stereo frames send two images and cost about twice as much) and that images leave the installation with a cloud model; a panel "KI-Hilfe" holds the per-user switch "Stereo-Bilder, wenn verfügbar" / "Stereo frames when available" (ai-preferences, saved at once withPUT /me/preferences, default on); "installation default available: " appears when the installation offers its model; without a secrets key on the installation the page explains that keys cannot be stored. Below the form the switch "Automatische KI-Analyse bei Fehlern" (auto_analysisofGET/PUT /me/preferences, saved at once,src/lib/vision/auto.ts, ADR 0035) with the installation's limits (cooldown, analyses per mower and day); it can only be switched on with a provider in effect and while the installation allows it (switching off always works), 403feature-not-in-planappears in words, and a note links to the notification settings for the kindvision_auto_proposal("KI-Vorschlag nach einem Fehler"). The sheet shows "Automatisch ausgelöst durch Fehler" for a proposal withtrigger = error. -
Operator console: System shows whether a hosted vision default is configured (
GET /api/v1/vision/status: on or off and the model string, never a key; "not deployed" when Vision does not answer) and probes Vision with the other services.
Module operator (/operator, icon shield, order 95) for the operator admins of the installation (platform:main#operator_admin, granted with python -m identity.cli grant-platform-admin USERNAME); it shows no mower data anywhere, and the services check every request again. The route layout (src/routes/(app)/operator/+layout.svelte) shows an inverted tab strip (accounts, plans, system) and, for everybody else, an "operators only" notice.
-
Accounts (
/operator): Identity's accounts of the installation (GET /identity/platform/accounts: name, kind, status, owner, created) joined by id with Entitlements' plans (GET /entitlements/accountsat the same offset, the rest withGET /entitlements/accounts/{id},src/lib/operator/accounts.ts): assigned plan, its status and the plan that applies now. 25 per page, oldest first. The search (300 ms after typing) reads Identity's whole list (up to 1600 accounts; Identity has no search parameter) and matches name, owner, id, kind and status. With billing off an info notice says that every account resolves tounlimitedwhatever is assigned. -
Account (
/operator/accounts/<id>): facts, the assigned and the effective plan side by side, the history (newest first: plan, status, source, reason, actor). "Assign plan" opens a sheet: the offered plans as large radio rows, the status, for trial and grace an optional end date (sent as the end of that local day; empty: the plan's trial days or the installation's grace days) and the required reason (PUT /entitlements/accounts/{id}/plan,src/lib/operator/assign.ts). -
Plans (
/operator/plans): the offered plans (GET /entitlements/plans; inactive ones open by key in the editor) with limits, trial days, default mark and features. The editor (/operator/plans/edit?key=<key>, withoutkeya new plan) edits name, description, trial days, default and offered, the six limits (empty is unlimited) and the features fromGET /entitlements/featuresgrouped by module, plus "all features" (*) and the plan's keys no deployed module declares; it sends the whole plan withPUT /entitlements/plans/{key}(src/lib/operator/plans.tsvalidates like Entitlements). With billing disabled a warning says thatunlimitedapplies to everyone; plans from the plan file say that a changed file entry replaces the edit at the next start. -
System (
/operator/system): what the app can read honestly: the billing switch (fromGET /entitlements/me), the plan marked default (the installation may name another one inENTITLEMENTS_DEFAULT_PLAN, which no API exposes), the number of offered plans, accounts and catalogue features, the hosted vision default of AI assist (on or off and the model, fromGET /vision/status), the modules that announce web areas or features, and whether each service answers a publicGETthrough the gateway (src/lib/operator/system.ts: Identity's JWKS, Entitlements' plans, Fleet's connection kinds, Telemetry's states, the Scheduler's schedules, the VAPID key, the Device-Gateway's mowers, Vision's status) with the status and the latency measured in the browser; bodies are dropped. The services'/readyzis not routed through Traefik, so this is reachability, not readiness; Realtime has no HTTP endpoint and shows only whether this tab's socket is connected. No other metrics.
Not in scope: support access (approved, time-limited, logged) and anything about Billing.
The add-mower page and the mower settings generate their connection forms from Fleet's GET /api/v1/fleet/connection-kinds (src/lib/connection/): the kinds this installation accepts (FLEET_ALLOWED_CONNECTION_KINDS), each with its options (type string, integer, number or boolean; required; secret; managed; group for alternatives such as a key or a key reference; advanced; pattern, minimum, maximum, default; English title and description) and linked_account (true for cloud: its mowers come from a linked vendor account, see Add mower and linked accounts). A new adapter kind therefore needs a schema in Fleet and no UI change.
-
form.ts(pure, unit tested):initialState(secrets start empty and are "set" when the server answers***; the chosen alternative is the one that has a value),validate(Fleet's rules: required options, one alternative per group, integer and number ranges, patterns such as the BLE MAC address; managed options are skipped),buildOptions(only the chosen alternatives; an untouched set secret goes back as***, which Fleet replaces by the stored value; managed keys are never sent; keys the schema does not describe are kept when the kind did not change, e.g. the simulator'stime_scale),serverIssues(Fleet's 422errors[].pointer#/connection_options/<key>with theircodemapped to the inputs). -
text.ts: German and English labels and help for the options ofcloudandble_bridge(matchinglibs/mowdeck-control-mammotion/README.md); anything else falls back to the schema's English texts. -
ConnectionForm.svelte: a plate with the kind and what it does, managed options as read-only readouts (managed-<key>: Fleet sets a cloud mower'sdevice_nameandiot_idfrom the linked account's device list), the options, a segmented choice per group, switches for flags, a "set" marker on stored secrets, "advanced" options folded. Client checks run on submit and then live; nothing is sent while one fails.
simulated has no options. Changing the kind of a mower warns that the gateway reconnects it; the options of the previous kind are not carried over. A mower cannot be switched to a linked_account kind in its settings (the page says to add it from the account and does not save). A cloud mower with linked_account_id shows "connected through" with the account's e-mail (GET /linked-accounts/{id}, a link to the settings page), the managed readouts and its editable advanced extras. A legacy cloud mower (no linked_account_id, its login in its own options) shows its options read-only with a hint to link the account and add the mower again; its options are not sent on save.
A mower keeps one primary connection and may carry additional links (ADR 0038); the first kind is the Bluetooth bridge (ble_bridge, an ESPHome Bluetooth proxy next to the mower) that carries manual driving while the cloud carries everything else. The mower settings page shows two panels under the connection form:
-
Bluetooth-Bridge (
bridge-panel,src/lib/components/BridgeLinkPanel.svelte) appears when the mower's capabilities listble_bridgeinlinks_supportedor when a link is stored (MowerOut.links.ble_bridge); the kind of the primary connection is not consulted. The empty state (bridge-empty) has "Bluetooth-Bridge hinzufügen" (bridge-add) and a link to the ESPHome guide. The sheet (BridgeLinkSheet.svelte; flowsrc/lib/mower/bridge-link.svelte.ts, pure helpersbridge-link.ts, both covered bybridge-link.test.ts) has three steps: host, key (the value ofapi.encryption.keyin ESPHome's secrets, a password field) and the port under "Erweitert" (bridge-host,bridge-key,bridge-advanced,bridge-port; required fields and the port range are checked after the first try); "Mäher suchen" (bridge-scan) postsPOST /fleet/mowers/{id}/links/ble_bridge/scan {host, port, noise_psk}with a 45 s deadline and shows "Suche nach {device_name} … (bis zu 30 s)" (bridge-scanning); the reply's devices become a radio list (bridge-device-list,bridge-device-<address>: name, address and signal in dBm), the mower whose name matches the registration'sdevice_nameis listed first and preselected, every other one is marked "anderer Mäher" (a lone other mower is preselected too); the switch "Messer beim manuellen Fahren" (bridge-blades) setsallow_manual_blades; "Speichern" (bridge-save) puts the link (PUT .../links/ble_bridge) and step 3 (bridge-saved,bridge-done) says the gateway opens it now. Errors per code (bridge-scan-errorwithdata-code):unreachable("Proxy unter {host}:{port} nicht erreichbar …"),auth_failed,timeout(with other mowers seen the list still opens, with the note),busy,already_connected("already connected through this bridge by the mower entry …" withheld_by),unsupported,not_found; a 409bridge-scan-unsupportedorlink-not-applicablereads as unsupported, a 503 means no gateway answered, other problems show the problem text. A stored link shows chips from the telemetry'sstate.links.ble_bridge(bridge-statuswithdata-connected): Verbunden/Getrennt, the RSSI (bridge-rssi), "ESPHome · kompiliert " (bridge-firmware), the badge "veraltet" with the reason (esphome_version,project_version,no_project) and the guide link (bridge-outdated,bridge-outdated-reason); before the gateway's first report it says so. "Erneut prüfen" (bridge-recheck) posts.../links/ble_bridge/check(the stored key) and toasts the proxy's name and version; "Entfernen" (bridge-remove,bridge-remove-body) asks once more and deletes the link. The key is write-only: the sheet sends it once, a saved link scans and saves with***, and no response or page ever contains it. -
Firmware (
firmware-section,FirmwarePanel.svelte, rules insrc/lib/fleet/firmware.tswithfirmware.test.ts): installed version (firmware-installed, fromfirmware_info.installed, elseMowerState.firmware), available version (firmware-available), the release notes folded (firmware-notes-toggle,firmware-notes), the state line (firmware-state: aktuell, Update verfügbar, Update läuft, letztes Update fehlgeschlagen with the reason infirmware-failed, nicht geprüft; with "geprüft vor …"), a progress bar while installing (firmware-progress), "Nach Updates suchen" (firmware-check,POST /fleet/mowers/{id}/firmware/check; the reply'sinfoshows until the telemetry reports a newerchecked_at; a 409firmware-check-unsupportedand a 503 in words) and "Update installieren" (firmware-install, only with an offered version; disabled with the first unmet precondition under it,firmware-blockerwithdata-reason: control right, update running, offline, job, docked, battery at least 70 %; charging is not required, since a full mower stops charging in its dock). The confirm sheet (firmware-confirm,firmware-confirm-go) says the mower is unusable for 10 to 20 minutes and sendsfirmware_update {version}through the ordinary command path (the result arrives as a toast). Withoutcapabilities.firmware_updatethe panel shows the installed version and a note (firmware-unsupported).
Elsewhere: the activity updating is part of the @mowdeck/ui Activity type (charging tone, its own glyph, "Firmware-Update") and disables every command button with the reason "Während des Firmware-Updates …" (the emergency stop stays); /drive shows "Bluetooth-Bridge getrennt …" instead of a dead joystick while state.links.ble_bridge.connected is false; the kind picker's ble_bridge description says the standalone kind has no camera and that a mower with a cloud account is better served by the additional link; the inbox and the notification settings know firmware_update_available, firmware_update_finished and firmware_update_failed. The link and firmware calls (src/lib/fleet/links.ts, firmware.ts) use fleetApi with the types generated from openapi/fleet.json (BridgeScanIn, BridgeScanReply, BridgeLinkIn, LinkOut); the scan and the re-check go through a client with a 45 s deadline, since the gateway scans for up to 30 s. LinkStatus and FirmwareInfo come from the generated Telemetry types.
MowDeck drives the mower itself over a small patch (ADR 0039): the Planner plans the spot, the web sends the plan inline in a spot_mow command, and the Device-Gateway runs it over the drive channel. The page never branches on a manufacturer: everything is offered by the capabilities (manual_drive, manual_drive_blades, cutting_width_mm, links_supported) and the permission can_drive.
-
Spot mode. "Spot mähen" (
spot-open, under the readouts) shows whencaps.manual_drive && canDrive. It putsMowerMapinto spot mode: the page passesonmaptap(local metres of a tap on the map itself; area taps are off meanwhile) and thespotoverlay (MapSpotoflibs/mowdeck-ui/src/lib/map/spot.ts: a circle, or a polygon drawn so far, with the corners and the centre; helpersspotPolygon,containingArea,distanceToSpot,thinRing, unit tested inlibs/mowdeck-ui/src/tests/spot.test.ts). A tap places a circle of 1.5 m (radius slider 0.5 to 3 m); "Form zeichnen" takes up to 8 taps as a polygon (the sheet opens at three, "Ecke zurück" undoes). The chip on the map (spot-chip) says what the next tap does and reopens the sheet. "Nachmähen" of a missed spot (HeatmapGaps.svelte,heatmap-gap-remow) opens the sheet with the gap's polygon. The spot must lie entirely inside one mapped area; that area's id goes to the Planner, which clips to it. -
Sheet (
SpotSheet.svelte; flowsrc/lib/mower/spot-sheet.svelte.ts, pure rulesspot-sheet.ts, covered byspot-sheet.test.tsandspot-sheet.svelte.test.ts): pattern spiral (inward) or stripes with an angle; path spacing opens at the cutting width minus 50 mm (at most the cutting width); speed 0.1 to 0.3 m/s, hard cap 0.3; blades off by default ("Trockenlauf"), the switch only when the mower reportsmanual_drive_blades, and then a second confirm checkbox; blades count only with both; turns at lane ends (ADR 0043) as four tiles (spot-turns,spot-turn-<style>): the mower's own setting with the style it resolves to, in place, arc, multi-point (disabled withoutcapabilities.drive_reverse, with a note when the wanted style falls back to arc); the preview asks with the resolved style (resolveTurnStyle, the mirror of the contracts'resolve_turn_style), the command carries the choice. Every change asksPOST /planner/plans/preview400 ms after the last one (one request at a time, keyed by the whole request); the route is drawn on the map's path-preview layer with duration, segments and warnings; lane-end turns are amber and reverse legs amber dashed (md-mowpath--turn,md-mowpath--reverse), with a legend under the preview (spot-legend) when the plan has turns. -
Phones (below 1024 px, the page's
phonebreakpoint): the sheet is aBottomSheetof@mowdeck/uiwith snap points over the map, which goes full screen while a spot is placed (it cannot be collapsed then) and stays visible and tappable above the sheet. The sheet sits above the command bar, so the emergency stop stays where it is.-
Peek (
spot-peek, at least 140 px): pattern and size (spot-peek-summary, "Spirale · 1,5 m", or the area of a drawn shape), the estimated duration (spot-peek-duration, "≈ 4 min", "< 1 min" for a run under a minute), the start conditions as one square lamp with the green count (spot-peek-conditions, "6/8"; a tap opens half scrolled to the rows) and START. While a run starts (flow.launching, until its first status arrives, at most 20 s), runs or has just ended, the peek shows the run bar instead (SpotRunBarwithembedded, its big STOP included) and the sheet is held at peek, so STOP never needs a drag; the run bar above the command bar is then not shown. - Half: shape and radius (or corners), pattern and angle, speed, turns.
- Full: spacing, blades with the second confirmation, every start condition with the freshness hint, the preview's details and warnings, cancel.
- The handle (
spot-sheet-handle,aria-expanded) cycles the snap points on a tap, Enter or Space, and follows a drag (a fling goes on to the next snap); Escape returns to peek. A tap on the map places or moves the spot at peek and at half and collapses the sheet to peek. Every newly opened sheet starts at peek. - The sheet reports the height it covers; the page passes it to
MowerMapasinsetBottom, so the fit, the scale and the spot with its live route preview stay above the sheet, and adds it to the page's bottom padding. The chip at the top left of the map (spot mode or the area selection) is passed asinsetTop, so the legend sits below it instead of under it (on desktop too). - Desktop keeps the side sheet.
-
Peek (
-
Start conditions as green or red rows (
spot-conditions,data-ok); "Starten" stays disabled until all are green and a preview for the current request is there: the spot lies inside one area; the drive link is up (state.links.ble_bridge.connected, orstate.connection === 'ble_bridge'; a connection that lists noble_bridgelink drives over its primary connection); when the bridge carries the driving, "Bluetooth-Signal ausreichend" with the mower's signal at the bridge (state.links.ble_bridge.rssi): green from -90 dBm (LINK_MIN_RSSI_DBMin$lib/mower/drive-link.ts, the mirror of the gateway's constant, which refuses the start below it), red below with the value and "move the bridge closer", neutral (data-ok="info", not blocking) with a hint while the bridge reports no value; RTK fix withposition_source === 'rtk'(not needed yet for a docked mower that can leave the dock: the run waits for RTK after undocking, ADR 0041); "Weg zum Spot gefunden" (the Planner preview'stransit.found, the same transit planner the gateway uses; no distance limit); the mower ready,activity === 'idle'or docked withcapabilities.undock; no job, no drive session of this browser, no spot run; battery above 20 %; the mower online by its link heartbeat (isOnlineof$lib/mower/presence) and its values younger than 60 s (a docked or idle mower on a cloud-first connection reports about every 20 s, and the bridge attaches only when the run opens its drive session; the gateway's run pauses while the pose is older than 2.5 s and waits up to 30 s, so it moves only on live positions; the hint under the rows says so). -
Start:
POST /planner/planswith the same body, then the commandspot_mow {plan, blades, confirm_blades, speed_mps, turn_style, spot}(spotis the audit record in WGS84: centre and radius, or up to 64 corners). The preview seeds the map's planned path; the gateway's path snapshot replaces it. -
Run view (
SpotRunBar.svelte, above the command bar) whilestate.spot_runis active: state in words, progress %, segment i of n, driven and planned points, the RTK badge, the dry-run or blades badge, and a big STOP (spot-stop) that sendsspot_stop; the emergency stop stays in the bar. When the run this page watched ends, the bar shows the end state (done,aborted,failed) with its reason in words (rtk_lost,geofence,link_lost: "Bluetooth-Bridge nicht erreichbar oder zu schwaches Signal" when the mower drives over the bridge, else "Fahrverbindung unterbrochen",stopped_by_user,emergency_stop,sensor_stop,timeout,heading_mismatch; an unknown reason as it came), "Nachmähen" with the same spot, and dismiss. A run already final when the page opens is not announced. -
Duration of the preview:
formatSpotDuration($lib/mower/duration): "< 1 min" below a minute, whole minutes below an hour (no 5-minute rounding for spots). -
Playwright
tests/e2e-web/tests/spot-phone.spec.ts(phone project, 390 x 844 with touch): the map stays visible after placing the spot, the sheet opens at peek, a tap or a drag on the handle goes to half, Escape and a map tap return to peek, the lamp opens the conditions, START (or, during a run, STOP) is reachable at peek; the run part is skip-guarded as below.tests/e2e-web/tests/spot.spec.ts(desktop project): spot mode, the tap, the sheet's limits and rows against the simulator. Whether the gateway runs spots is probed with aspot_stopover the API;unsupportedskips the run part cleanly, and so do unmet conditions (the simulator starts docked).
/mowers/new starts with the kind (#connection_kind). Kinds without linked_account (simulated, ble_bridge) keep the single-mower form (name, model, serial, the generated connection form, mower-submit, POST /mowers). A linked_account kind (cloud) swaps in LinkedMowerFlow.svelte (src/lib/linked/: accounts.ts pure and unit tested, api.ts the requests):
-
Account. The linked accounts (
GET /linked-accounts: e-mail, last read, device count, last error) as a radio list; a single account is chosen at once, none opens the connect form, "link another account" opens it beside the list. A 403 (the user lackscan_manage_mowerson the account) is an info notice, not an error.LinkedAccountConnect.svelte: e-mail and a write-only password (cleared after every attempt, never prefilled).POST /linked-accountslogs in to Mammotion and can take close to a minute, so the request usesfleetSlowApi(90 s deadline instead of 30 s) and the form shows a calm pending line. A refused login (422linked-account-login-failed, pointer#/password) lands on the password field ("E-Mail oder Passwort wurde von Mammotion abgelehnt"), 409 (linked already) on the e-mail, 503 (no encryption key, no gateway, cloud down) as a notice with Fleet's detail. After linking, the flow continues with that account and the devices of the answer. -
Devices.
LinkedDeviceList.svelte: name, model, IoT id (mono), online, offline or unknown (word and glyph); addable mowers first with a checkbox, then greyed rows with the reason: "already added" (registered), "registered in another account" (registered_elsewhere: one physical mower belongs to one MowDeck account installation wide) and non-mowers ("base station"). "Read account again" (POST /linked-accounts/{id}/sync) and the time of the last read sit in the head; a failed read shows its problem and the account'slast_sync_error. -
Drafts. Per selected mower (at most 50): the display name (prefilled with the device name) and the site: the sites of
GET /sites?include_default=true(the default site first and translated) or "new site", which shows a name input, creates the site in place (POST /siteswith the browser's time zone; a 409 shows at the input) and selects it. -
Add. One
POST /linked-accounts/{id}/mowers; when everything was created, a toast and/mowers. Devices infailedstay selected and show their reason in the list and above the button; the created ones turn "already added". When nothing could be created Fleet answers with the first problem (e.g. 409mower-already-registered).
Settings page /settings/linked-accounts (module linked-accounts, icon link, fleet.manage, core: no ui_area of its own): every account as a LinkedAccountCard.svelte with e-mail, provider, device and mower counts, the last read and the last error (prominent, red edge), "read again" (optionally "with a new password", write-only) and "unlink" with a confirmation; a 409 linked-account-in-use lists the mowers that still use it (errors[] pointers #/mowers/<id> with the names, linked to their settings). Below the list, the connect form.
Session (src/lib/auth/session.svelte.ts, ADR 0021): the access token lives only in memory; the refresh token only in Identity's HttpOnly cookie mowdeck_refresh (login and refresh send X-Mowdeck-Refresh: cookie-only). The token is refreshed 60 s before expiry under the Web Lock mowdeck-refresh; a tab adopts a token another tab broadcast on mowdeck-auth within the last 30 s, and logout is broadcast to every tab. Never store a token anywhere else, and never let the service worker see one.
-
One refresh per expiry. Concurrent 401s share one refresh (single flight in the tab). A request whose 401 arrives after the token was already replaced (it went out with the old one), the socket's 4401 and a tab that waited for the lock while another tab refreshed all take the new token instead of refreshing again (
validToken(true, rejected)); every request retries once. - Nothing waits forever. The lock is given up after 15 s (a frozen or stuck tab must not block the others; the refresh then runs without it), the refresh request is aborted after 10 s, and every API request fails after 30 s. The lock callback always settles, so the lock is always released. A refresh that cannot reach Identity, or gets a 5xx or 429, keeps the token and retries in 30 s; a refused refresh (4xx) ends the session and the app layout sends the user to the login.
-
Loading states. The pages' "Loading …" line (
SlowLoading.svelte) turns into "Loading takes unusually long" with "Try again" (a reload) after 15 s. The mower list and the mower page show the last answer of this tab (src/lib/stores/last-load.ts, memory only, cleared on logout) at once and while they reload; a failed reload of the mower page keeps the shown data and reports the problem as a toast, the list keeps it under an error notice with "Try again". No page waits for the socket.
Realtime (src/lib/realtime/client.svelte.ts): one socket per tab. Manual driving uses it too: startDrive(mowerId, bladeOnAllowed), sendFrame(frame, force) (rate limited to the session's max_rate_hz), stopDrive(), the drive session state and onDrive(listener) for started, ack, ended (with the reason, connection_lost when the socket closed) and error; a drive_started nobody waits for any more is stopped at once. Pages call realtime.watch(ids) in an $effect and get a release function; subscriptions are reference counted and released after 2 s, sent in chunks of 200. Reconnects use full jitter backoff (0.5 s times 2^n, at most 30 s); 4401 fetches a fresh token (passing the refused one), 4429 waits at least 30 s and shows a banner, 4430 retries once at once. An application ping every 25 s with a 10 s pong timeout detects half-open sockets; every token refresh re-authenticates the open socket.
Stores (src/lib/stores): mowers keeps one state per mower, the newest observed_at wins and, for the same report, the newest heartbeat_at.
Online and offline (ADR 0040, src/lib/mower/presence.ts): a mower is online while the gateway holds a live connection to it, not while its values are young. The Device-Gateway republishes the latest state every 10 s with a fresh heartbeat_at and the unchanged observed_at, so mowers.isOffline(id) is true when the state says connected: false (the gateway lost the link) or the last heartbeat is older than OFFLINE_AFTER_MS (45 s, four missed heartbeats); states without heartbeat_at fall back to observed_at. Offline mowers get the dashed badge and the hollow map marker (stale props of @mowdeck/ui), and firmware updates and driving are blocked. The mower page shows the link line under the badge ("Verbunden", or "Offline, zuletzt verbunden vor 3 min") and, apart and subdued, the age of the values ("Werte von vor 4 min") only while the mower is online and observed_at is older than VALUES_OLD_AFTER_MS (3 min); a docked mower legitimately reports rarely. The socket indicator (LiveStatus, "Offline, letzte Werte") is about the browser's own WebSocket, not the mower. commands marks a sent command pending until the socket's command_result arrives, or polls its status_url every 2 s when nothing came within 8 s. ui holds toasts, the theme, the install prompt and the service worker update. src/lib/live.svelte.ts wires session, socket and stores together. src/lib/mower/commands.ts decides which command buttons are available in which activity.
-
Unit and component tests: Vitest with jsdom and Testing Library, files
*.test.tsnext to the code inapps/web/src/liband inlibs/mowdeck-ui/src/tests.pnpm --filter web testenforces 80 % line coverage for the session, the Realtime client, the stores, problem parsing and command availability. Inject fakes through the constructors (socketFactory, clocks,random) instead of mocking modules. -
Playwright (
tests/e2e-web, Chromium) against the running Compose stack, base URLMOWDECK_E2E_BASE_URL(defaulthttp://localhost):docker compose -f infra/compose/docker-compose.yml up -d --build --wait pnpm --filter e2e-web exec playwright install chromium # once pnpm --filter e2e-web test # projects phone (gate steps 1 to 11) and desktop (layout, notifications, connection forms, schedules, drive, health, fleet, operator console, camera) pnpm --filter e2e-web screens # writes apps/web/screenshots/<page>-<phone|desktop>.png
The project
phone(390 x 844) runs the Phase 2 gate ingate.spec.ts: registration in German, a simulated mower docked, readouts and the map with both areas and the dock, both areas tapped on the map and confirmed in the picker with the stop in view, start of both areas live, the chevron moving and the route growing on the map (step 11, also full screen with the stop in view), blade height while mowing, return to dock, a Viewer without command buttons who can still stop, a foreign account that sees nothing, language and theme switch, logout. A CSP violation on the console fails any test. The projectdesktop(1280 x 800) runsdesktop.spec.ts,drive.spec.ts(Phase 3 gate item 4: the mower page offers "drive manually", a session starts, the pad held forward for 2 s with real pointer events moves the mower, after letting go its speed is 0 within 2 s and it stays put, Stop ends the session and Fleet'sGET /mowers/{id}/drive-sessionshas it withclient_stop),calibration.spec.ts(a simulated mower in real time,time_scale1, driven out of the dock with the joystick until it stands about 1.5 m into the front lawn; the settings page shows every calibration start condition green, the room confirmed, START runs the calibration to its end notice and step table; then the page has a single vertical scroller, the shell's main area, and no box around the panel scrolls vertically, the step table's sideways scroll box included),health.spec.ts(item 5: after a simulated job the health page shows blade and mowing hours and distance above zero, today's bar and the battery trace; the owner records a blade change with a note; a Viewer sees the page without "blades replaced" and the mower page without "drive manually"),schedules.spec.ts(the site gets the map origin as its coordinates; a weekly schedule created in the form one or two minutes ahead with both areas tapped fires and the run isstartedwith the mower mowing, shown on the mower page as the next schedule and on the runs page with its command link; skip next; with the weather switched to rain through the development endpoint, run now with the rain pause respected isskipped_rain; a Viewer sees the list without create, edit, skip or run controls and the schedule read-only),fleet.spec.ts(Phase 4 Task 21: a second site created in the sheet with the coordinates of a mower's map origin, a group at that site, a mower moved there and into the group, a sub-user given the Operator role at the group level who then sees only that mower in the list and on the fleet map and gets 403 for a bulk command; "pause all" on the group answersacceptedfor the mower and pauses it; the reports page shows jobs, mowing time and availability after the job, filters by site, and the CSV download starts with the BOM and the expected header),operator.spec.ts(Phase 4 Task 22: a fresh user made an operator admin sees the operator module, a normal owner does not and gets the "operators only" notice on/operator; the owner's account page shows the planunlimitedwith the "no subscription limits" note; the operator finds the owner's account by search, assignsfleetwith a reason after the empty reason was refused, and sees it in the history and in Entitlements' record; the plans page listshomeandfleetand opens the editor; the system page shows Identity, Entitlements and Fleet answering),notifications.spec.ts(preferences and an ntfy target saved and reloaded, the test notification 202 then 429 with the cooldown, the unread badge appearing and clearing, a simulated job ended at the dock arriving as "mowing finished"; the stack setsNOTIFICATIONS_MOWING_MIN_SECONDS=10)camera.spec.ts(Phase 5 Task 25: the simulated mower'sPOST /camera/sessionanswers afakesession with two cameras andno-store; the mower page has no camera button but the inline panel, open by default, the test pattern canvas keeps drawing frames and is not blank, the picker switches to "Front rechts" and the active uid follows, "Beide" shows both front cameras side by side with both tiles live, their own names, both patterns drawing and different pictures (the right one mirrored), no "same picture" or "no data" notice, and back to one camera; on the phone the two tiles stack (writescamera-both-phone.png), the panel collapses (no panel, a note), stays collapsed after a reload and expands again; the full-screen page shows the pattern and the emergency stop, and back on the mower page the panel is open; a Viewer sees the panel too; writescamera-phone.png)spot.spec.ts(spot mode, the sheet and, when the gateway offers spot runs and the conditions are green, a dry run stopped with STOP; see "Spot mowing") andconnection.spec.ts(the add-mower form offers the kinds ofGET /connection-kinds; forclouda wrong password shows the field error, the right one links aconn-<run>@fake.mowdeck.testaccount, the device list showsLuba-FAKE0001online,Luba-FAKE0002offline andRTK-FAKE0001greyed as no mower, "read again" works, both mowers are added in one go with one renamed and one on a site created in place, they appear in/mowerswith their IoT ids, the mower settings show the account's e-mail and the managed device read-only, a second MowDeck account linking the same cloud sees themregistered_elsewhere, and the settings page lists the account and refuses to unlink it while the mowers use it; the test always deletes its cloud mowers and linked accounts, because the fake devices have fixed IoT ids and one physical mower belongs to one account installation wide; able_bridgemower saved with a noise key shows it as set and no response ever contains it, it points at a documentation address and is deleted; the Bluetooth bridge sheet of a simulated mower's settings scans, saves, shows and removes the link and the firmware check fills the panel, each part recorded as skipped when the stack does not offer it yet: noble_bridgein the capabilities, a 503 from Fleet orunsupportedfrom the gateway). The stack runs the Device-Gateway with the fake Mammotion cloud (GATEWAY_FAKE_MAMMOTION_CLOUD=trueinstack.env): any*@fake.mowdeck.testaddress logs in withfake-cloud-password, other passwords are refused (422linked-account-login-failed), and the account ownsLuba-FAKE0001(online, modelLUBA 2 AWD 5000X, IoT idfakeiot0001),Luba-FAKE0002(offline,fakeiot0002) andRTK-FAKE0001(not a mower). CI runstestin the jobe2e-weband uploads the report and the Compose logs when it fails.operator.spec.tsneeds an operator admin, which only Identity's CLI creates:grantPlatformAdminintests/support.tsrunsdocker compose -f <file> exec -T identity python -m identity.cli grant-platform-admin <user>, so the Docker CLI must reach the stack (MOWDECK_E2E_COMPOSE_FILEoverrides the defaultinfra/compose/docker-compose.ymlof the checkout;MOWDECK_E2E_PLATFORM_ADMIN=offskips the spec, e.g. against a remote stack). The suite needs the overrides intests/e2e-web/stack.env(static weather provider,SCHEDULER_DEV_ENDPOINTS=true, a 5 s ticker), which CI merges into the stack's.env; for a local run append them toinfra/compose/.env(later lines win). -
Screenshots for pull requests:
screenswriteslogin,mowers,mower-detail,mower-detail-dark,mower-map,mower-map-dark(full screen on the phone, RTK tint on the desktop)settings(the mower settings page),notifications(notification settings) andinboxat phone and desktop size, plusconnection-cloud-phone(the linked account connect form with a missing password) andconnection-ble-desktop(the generated connection form with a validation message),schedules-phone,schedule-edit-desktop,schedule-runs-phonemower-map-rtk-desktop(the RTK heat layer of the last 24 h; the simulated mower always reportsfixed, so the cells are green) andmower-map-coverage-desktop(the coverage layer with the missed spots),health-phone,health-desktopanddrive-phone,drive-desktop-dark(a live session with the stick held).operator.spec.tswritesoperator-accounts-desktop,operator-plan-sheet-phone(the assign sheet withfleetchosen) andaccount-plan-phone(the plan section with billing disabled).camera.spec.tswritescamera-phone(the full-screen camera with the test pattern). The testfleet screenshotswritesfleet-map-desktop,fleet-map-phone(three simulated mowers at two sites, placed with the simulator optionsorigin_latandorigin_lon),sites-phoneandreports-desktop.
nginx sends a strict Content Security Policy (apps/web/nginx/security-headers.conf, build/csp.conf): scripts and styles from 'self' only, plus the SHA-256 hashes of the inline bootstrap script that scripts/csp-hashes.mjs computes from the built HTML. Therefore:
- No inline
<script>of your own and no inline<style>blocks or staticstyle=""attributes in HTML; the build fails when one appears. Set dynamic styles through Svelte'sstyle:directive or CSS custom properties (the CSSOM is not restricted). - No external hosts: fonts are self-hosted (
fonts.css), no CDN. The exceptions are an optional tile server, which the container adds toimg-srcfrom its configuration (see Map), and the Agora domains of the live camera, added only withMOWDECK_WEB_CAMERA_ENABLED=true(see Live camera). - Leaflet positions its elements through the CSSOM, which the CSP allows; markup handed to Leaflet (
divIcon) is built with DOM calls, never withstyle=""attributes or HTML strings. - Code that must run before the app (the theme) goes into an external file under
static/liketheme-init.js. - The service worker never handles
/api/requests. -
vite.config.tssetsassetsInlineLimit: 0, so no asset becomes adata:URL in a stylesheet.
Mirrored from docs/dev/web-app.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- Development
- Operations
- Spikes
- Guides
- Original plan (German)