Skip to content

Dev Web App

github-actions[bot] edited this page Oct 15, 2026 · 17 revisions

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.

Workspace

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).

Dev loop

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:5173

pnpm 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 fix

A plain pnpm -r test also runs the Playwright suite of tests/e2e-web, which needs the running stack (see Testing).

Generated API types

Never edit apps/web/src/lib/generated/. The types come from committed sources in two steps:

  1. uv run poe openapi (script tools/repo-checks/scripts/export_openapi.py) builds every service in memory and writes apps/web/openapi/<service>.json (sorted keys). uv run poe openapi-check and the repository test tools/repo-checks/tests/test_openapi_export.py fail when a committed document is stale.
  2. pnpm codegen (tools/ts-codegen/gen.mjs) writes identity.ts, fleet.ts, telemetry.ts, device-gateway.ts, notifications.ts, scheduler.ts with openapi-typescript and realtime.ts from the contracts' RealtimeServerMessage and RealtimeClientMessage JSON Schemas with json-schema-to-typescript. pnpm --filter ts-codegen check fails 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.

Module system

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:

  1. Add the route under src/routes/(app)/<route>/+page.svelte (pages stay thin; reusable parts go to libs/mowdeck-ui or src/lib/components).
  2. Add src/lib/modules/<name>/module.ts (the registry finds it with import.meta.glob), the label message in both messages/*.json and, if needed, an icon in Icon.svelte of mowdeck-ui.
  3. Announce the route in the backing service's module descriptor (ui_areas with route, menu_label, optional labels and required_permission), unless the module is core.
  4. Check per-object rights on the page with POST /api/v1/identity/me/permissions (cached per mower for 60 s in src/lib/live.svelte.ts); me.permissions is 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.

Settings page and desktop shell

  • One settings page. Modules with placement: 'settings' are not navigation entries but sections of /settings (src/routes/(app)/settings/+page.svelte, components in src/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, core or ui_area); area names 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-accounts and /settings/api-tokens render SettingsForward, which replaces the address with /settings?<query>#<section> (forwardTarget in src/lib/settings/sections.ts; an anchor of the old link wins, so Stripe's /account?billing=success#plan lands 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 AppShell is exactly 100dvh high with overflow: 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.

i18n

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 (in libs/mowdeck-ui/lint/) reports text nodes and aria-label, title, placeholder and alt values with letters in apps/web/src/**/*.svelte (units such as mm, m/s, % are allowed). Use m.some_key().
  • Keys are snake_case with 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 Intl with the active locale (decimal comma in German); units are not translated.
  • pnpm --filter web i18n:check (part of lint) 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 to detail.

Design system

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 rule mowdeck/use-tokens rejects raw values everywhere else. Spacing uses the 4 px grid tokens, motion the --md-motion-* tokens, touch targets at least 48 px (--md-target 48 px, --md-target-command 56 px, --md-target-stop 64 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-end sends return_to_dock with end_job: true (the job ends, primary button), dock-choice-keep with end_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)" for end_job (CommandView.end_job).
  • Themes: System, Light, Dark. static/theme-init.js applies the stored choice (localStorage key mowdeck.theme) before the first paint; tokens.css switches with prefers-color-scheme unless data-theme overrides it.
  • pnpm --filter @mowdeck/ui tokens:check fails when a token pair misses its contrast target (part of the library's test).

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-motion respected

Map

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 plus GeoReference); the page keeps the ETag and revalidates once a minute with If-None-Match (304 keeps the map). The mower comes from the live state store, the driven route from GET /api/v1/telemetry/mowers/{id}/positions?from=...&limit=5000 for the window chosen in the route panel (1 h, today, 24 h; remembered per browser in localStorage) 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's CRS.Simple in 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 uses EPSG:3857 and converts local metres to WGS84 with the TypeScript port of mowdeck_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, testid heatmap-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 404 no-map-yet, an error with the translated problem, the number of samples, "coarsened to N m" when the answer's cell_m is bigger than requested, "partly evaluated" for truncated). The data comes from GET /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 in localStorage key mowdeck.heatmap). "Letzter Auftrag" runs from the newest start_mow among the mower's recent Fleet commands to the next return_to_dock (or now); without a start in the list it shows the last 24 h and says so. Cells (x, y integer indexes, cell lower-left at x * cell_m, y * cell_m local metres) are drawn by MowerMap (heat prop) 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-error through --md-state-paused to --md-state-mowing, labelled "schwach" ... "stark". The map element carries data-heat-cells, data-heat-kind and data-heat-gap.
  • Missed spots. With the coverage layer, HeatmapGaps (testid heatmap-gaps) lists the answer's gaps under 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 (highlightGap of MowerMap: 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's blade_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" sends set_blade_height and/or set_speed first, only for values that changed (src/lib/mower/setpoints.ts, setpointCommands), one after the other, then start_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 by MowerMap (path prop, 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 to progress.index solid, the part ahead dimmed (splitPathProgress cuts the active polyline so both parts meet), transfers dotted, with a legend (mow-path-legend: "Mähpfad", "Gemäht", "Geplant", and the caption "Vorschau" with pathPreview). The map element carries data-path-points, data-path-done and data-path-preview. The detail page (src/lib/mower/mow-path.ts) polls GET /api/v1/telemetry/mowers/{id}/path every 10 s while the activity is mowing, paused or returning (404 no-path until 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 publishes path: null (or 404) and the mower is docked, charging or idle (the poll continues at rest while a job's path is held), when a return_to_dock with end_job was 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, commandClearsPath in src/lib/mower/mow-path.ts); the toggle "Mähpfad" (mow-path-toggle, in the heat strip, localStorage key mowdeck.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-preview with the sheet's areas and pattern, fleetSlowApi, 90 s) to let the mower plan without starting; start-preview-status says 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; localStorage key mowdeck.rtkRange) loaded from /positions in pages of 5000 (Telemetry's row cap) going back with to set 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's rtkPoints, showRtk, rtkCellSize, rtkAggregate, binned by libs/mowdeck-ui/src/lib/map/rtk.ts) is deprecated; it stays in the layer panel only for callers that do not pass heat.
  • Layer model. layers.ts turns 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, areaNamer and fleetAreaNamer in src/lib/mower/map-labels.ts, used by the mower page, its area list and picker, the map labels, the schematic AreaPlan, 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 as 4525245168006937683), else "Bereich N" / "Area N" with N the position in the map's area order; the same rule as mowdeck_contracts.v1.area_display_name. areaNamer(map.areas, mower.area_names) gives the map components their areaName function; without an index areaLabel keeps 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, onarealabel of MowerMap) 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 with PUT /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_control tap 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 sends start_mow {area_ids, pattern} in that order. Areas are keyboard reachable (focusable paths with aria-pressed).
  • Pattern settings at the start (MowSettings.svelte, logic in src/lib/mower/mow-pattern.ts, testid start-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> with aria-checked; only the names of capabilities.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 for edge_only and random_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 within capabilities.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's MowerOut.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 sends start_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 with PUT /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" from CommandView.pattern (commandLabel(kind, endJob, pattern), patternSummary in $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: Sheet has a footer that 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 is wide (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-fill from 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 reuse Stepper with immediate (commit at every step, the draft goes out with the start) in a grid of auto-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 on start-settings-toggle when it is visible (phone).
  • Start sheet flow (src/lib/mower/start-sheet.svelte.ts, StartSheetFlow, unit tested in start-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, then start_mow, then the mow defaults when they differ from what the sheet opened with) and the dock choice. Its StartSheetDeps port takes the API calls (preview, setpoint, start, dock, save defaults), the map's path hooks, a clock and a context() getter for what the page knows (areas, offered patterns, ranges, reported setpoints, rain bars); the pure rules (previewFailure, defaultsChanged, jobMinutes, rainStatusOf, jobActive) sit in src/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.ts requires agreement within 1e-9 degrees.

Runtime configuration (/config.json)

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.

Notifications and Web Push

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 in src/lib/notifications/runtime.ts): the newest 50 items and the unread count. There is no Realtime message for new notifications, so the store polls GET /inbox every 60 s while the app is open (the shell starts it for the navigation badge) and refreshes on focus and 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_KINDS in kinds.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]/map forwards 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-After and disables the button). Every failure shows the problem+json title, detail and correlation id.

Web Push flow

  1. pushSupport() (src/lib/push/device.ts) decides what the page offers: insecure (no secure context: Web Push needs HTTPS, plain HTTP works only on localhost), 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, or supported.
  2. "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-subscriptions with toJSON(). The returned id is kept in localStorage (mowdeck.push-subscription) to mark "this device" in the list; the VAPID key goes to the Cache API store mowdeck-push for the service worker.
  3. The service worker (src/service-worker.ts) handles push (parses the WebPushPayload with src/lib/push/payload.ts; shows title, body, tag so a repeat of the same kind and mower replaces the earlier notification, and the page in data.url, reduced to a path on this origin; a payload it cannot read still shows a notification, as userVisibleOnly requires), notificationclick (focuses a window already on that page, otherwise sends the open window a navigate message so the app routes itself and keeps its in-memory session, otherwise opens a new window) and pushsubscriptionchange (re-subscribes with the stored key).
  4. 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 sends push-renewed (PushDevice.syncPending()), and deletes the replaced one. The shell cache clean-up keeps mowdeck-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.

Schedules

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/permissions answers can_edit_schedule and can_control on it; run now needs can_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, or rain 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] (ScheduleForm in ScheduleEditor): 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 (MowerMap in 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.ts mirrors the Scheduler's rules (validate_schedule and 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 (SiteLocation on 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's GeoReference origin. It needs can_manage_mowers on the site (read-only otherwise; Fleet decides on PATCH /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 a RainStrip and, when the next schedule has a rain pause that holds, "Regenpause aktiv bis ..." (last wet hour plus cooldown). The page reads GET /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 verdict no_location shows "kein Standort" with a link to the mower settings; verdict disabled (provider off) and unknown_* (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-wet says "Rasen nass, trocken ab ca. HH:MM" or "Rasen trocken" from the decision's wet/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 preview points_total * 1 m / speed, else area / (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-warning names 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.

Manual drive

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 linear and angular in -1..1 and blade_on; every frame lives ttl_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, then drive_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 reports connection_lost and refuses frames). A job that is mowing or returning blocks the start (pause or end it first). drive_ended reasons are toasts in German and English (src/lib/drive/labels.ts).
  • Cadence (src/lib/drive/pilot.ts, DrivePilot). The Joystick samples at the server's max_rate_hz while 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 than 1 / max_rate_hz (10 % slack) before they reach the socket. Without capabilities.drive_reverse (ADR 0043) the pilot cuts backwards deflections to linear 0 (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 with blade_on_allowed then, 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-man ttl_ms, the server's max_rate_hz and the time left of max_seconds.
  • Screen wake lock (navigator.wakeLock, where the browser offers it) while a session is active.
  • Map: a small MowerMap with follow on and the route of this visit. The virtual fence (no-go zones) is enforced by the simulator and the adapters, not by the page.

Live camera

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 a fake session. The response holds a short-lived token (Cache-Control: no-store); the app keeps it in memory only.
  • Players (src/lib/camera). provider: "fake": FakePlayer draws 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.ts is imported dynamically, so agora-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 (default rtc; live joins 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 (a CameraChoice is a uid, null or a list of uids in tile order; FakePlayer draws one canvas per tile, the right camera of the pair mirrored). It never calls track.play(): the SDK's player applies inline styles the CSP refuses (style-src has no 'unsafe-inline'), and so would its autoplay prompt (switched off with onAutoplayFailed). Instead media.ts puts the raw MediaStreamTracks (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's muted. 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 s client.getRemoteVideoStats()[uid] and client.getRTCStats() and shows, in monospace: receive bitrate (kbit/s), frame rate, resolution, stalls (freezeRate of the last second and totalFreezeTime), current packet loss (currentPacketLossRate, the last 400 ms; the SDK reports fractions 0 to 1, shown in percent) next to the cumulative packetLossRate since the join (lost / (received + lost)), the delay with its source (end2EndDelay when above 0, else receiveDelay, transportDelay or the RTC RTT; - when none) and RTT, the connection state, the counts of connection-state-change, user-published and user-unpublished events since the join and the rejoins, and timestamps of the last three events. With localStorage.setItem('mowdeck.cameraDebug', '1') the same snapshot also goes to console.debug every 2 s (even with the line closed). The statistics are read every 2 s whenever a real camera plays, also with the line closed: tracks holds 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 localStorage mowdeck.cameraOptions; options.ts): channel profile rtc (default) or live (createClient({ mode }); live joins with setClientRole('audience', { level }), level 1 low latency or 2 ultra low latency, shown only for live); 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 sets 0); 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-change to DISCONNECTED (reason not LEAVE) 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); RECONNECTING for more than 20 s rejoins at once with a fresh session. Without the option the stream ends with "interrupted". token-privilege-will-expire requests a new session and passes its token to client.renewToken(token). user-published of a uid after its user-unpublished subscribes 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, live or waiting (joined, no picture yet), error, closed. A fresh session is requested a minute before expires_at (at the latest at 80 % of its lifetime) and when the SDK fires token-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.remoteUsers with hasVideo, updated on user-published, user-unpublished and user-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's placement (front_left "Front links", front_right "Front rechts", front, rear), else from well-known ids (Mammotion slots 0, 1, 2; the simulator's front_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, stereoPair in session.ts). When the session has a front_left and a front_right camera 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 (localStorage mowdeck.cameraLayout, default single). 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") or no_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 (403 feature-not-in-plan), not reachable (503, also when the mower is offline), mower not found, not a secure context (WebRTC needs HTTPS or localhost), live video switched off on this installation (cameraEnabled false), 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' and worker-src 'self'. With MOWDECK_WEB_CAMERA_ENABLED=true (Compose WEB_CAMERA_ENABLED, Helm config.WEB_CAMERA_ENABLED) the container adds https://*.agora.io wss://*.agora.io https://*.sd-rtn.com wss://*.sd-rtn.com and the same hosts with :* to connect-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/) and blob: to media-src and worker-src, and the hash 'sha256-cPxxYHvCiQ/kT6TlojjBofFPdVQmCg8k/CdPKQDf9zI=' to style-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.ts renders the CSP with and without the camera), and /config.json says "cameraEnabled": true. Without it an agora session 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, testids light-control with data-state on/off/unknown and data-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 a role="switch" button that sends set_light with 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 no light, while the mower is offline (activity offline or no state) and without can_control. A mower with a light but no camera gets the tile in the "Steuerung" panel instead. Fleet needs the plan features control and camera for set_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.

Health

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_threshold from GET /health as 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-reset with an optional note) needs can_manage_mowers (asked with POST /me/permissions first; a 403 hides it).
  • The emergency stop is in the bar at the bottom, as on every mower view.

Fleet

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, FleetMap of libs/mowdeck-ui): positions from GET /api/v1/telemetry/accounts/me/mowers/states plus 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 (geo projection); 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). BulkSheet lists 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 need fleet.bulk_command (an upper bound from me.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} with site_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 is PUT /mowers/{id}/groups with 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's POST /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

/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 with list_objects(can_view, mower)). Telemetry does not know sites, so the site filter uses Fleet's site_id of 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 a total line. File name mowdeck-report-<scope>-<from>_<to>.csv. "Copy as table" puts the same rows tab separated on the clipboard (pastes as columns into a spreadsheet).

Plans

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.ts translates 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 grace or expired a 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.

Billing

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 pages

/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

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 with can_drive: "Recover with AI assist" (assist-open-recover) while the state is error, "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-modal Sheet above 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" posts POST /mowers/{id}/recover with task and the camera panel's frames (src/lib/vision/snapshot.ts): every live tile's picture carries data-camera-video, data-camera-name (the session's camera name), data-camera-side (left/right in the side-by-side view) and data-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 own requestVideoFrameCallback and 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 carries camera, side and captured_at. Otherwise one JPEG of the first live tile (canvas, at most 1280 px wide, quality 0.8, base64, with camera). 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; 409 vision-provider-missing links to the settings; 429 vision-quota and 403 feature-not-in-plan appear in words.
  • Proposal: polled every second while final is false (AssistStore in src/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" from frames and a "Stereo" mark with the hint that the model may estimate distances from the parallax, testids assist-stereo and assist-images; below it "Aufnahme mit Kameralicht" / "Taken with the camera light on" with a lamp and a short explanation when light_used is true, testid assist-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 /retry with a fresh snapshot, while can_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_proposal links 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"; module ai-provider, shown when Vision announces the ui_area and the user holds mower.drive): family cards from GET /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 with PUT /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_analysis of GET/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), 403 feature-not-in-plan appears in words, and a note links to the notification settings for the kind vision_auto_proposal ("KI-Vorschlag nach einem Fehler"). The sheet shows "Automatisch ausgelöst durch Fehler " for a proposal with trigger = 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.

Operator console

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/accounts at the same offset, the rest with GET /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 to unlimited whatever 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>, without key a new plan) edits name, description, trial days, default and offered, the six limits (empty is unlimited) and the features from GET /entitlements/features grouped by module, plus "all features" (*) and the plan's keys no deployed module declares; it sends the whole plan with PUT /entitlements/plans/{key} (src/lib/operator/plans.ts validates like Entitlements). With billing disabled a warning says that unlimited applies 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 (from GET /entitlements/me), the plan marked default (the installation may name another one in ENTITLEMENTS_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, from GET /vision/status), the modules that announce web areas or features, and whether each service answers a public GET through 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' /readyz is 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.

Connection forms

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's time_scale), serverIssues (Fleet's 422 errors[].pointer #/connection_options/<key> with their code mapped to the inputs).
  • text.ts: German and English labels and help for the options of cloud and ble_bridge (matching libs/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's device_name and iot_id from 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.

Bluetooth bridge and firmware

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 list ble_bridge in links_supported or 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; flow src/lib/mower/bridge-link.svelte.ts, pure helpers bridge-link.ts, both covered by bridge-link.test.ts) has three steps: host, key (the value of api.encryption.key in 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) posts POST /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's device_name is 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) sets allow_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-error with data-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 …" with held_by), unsupported, not_found; a 409 bridge-scan-unsupported or link-not-applicable reads as unsupported, a 503 means no gateway answered, other problems show the problem text. A stored link shows chips from the telemetry's state.links.ble_bridge (bridge-status with data-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 in src/lib/fleet/firmware.ts with firmware.test.ts): installed version (firmware-installed, from firmware_info.installed, else MowerState.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 in firmware-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's info shows until the telemetry reports a newer checked_at; a 409 firmware-check-unsupported and a 503 in words) and "Update installieren" (firmware-install, only with an offered version; disabled with the first unmet precondition under it, firmware-blocker with data-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 sends firmware_update {version} through the ordinary command path (the result arrives as a toast). Without capabilities.firmware_update the 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.

Spot mowing

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 when caps.manual_drive && canDrive. It puts MowerMap into spot mode: the page passes onmaptap (local metres of a tap on the map itself; area taps are off meanwhile) and the spot overlay (MapSpot of libs/mowdeck-ui/src/lib/map/spot.ts: a circle, or a polygon drawn so far, with the corners and the centre; helpers spotPolygon, containingArea, distanceToSpot, thinRing, unit tested in libs/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; flow src/lib/mower/spot-sheet.svelte.ts, pure rules spot-sheet.ts, covered by spot-sheet.test.ts and spot-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 reports manual_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 without capabilities.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 asks POST /planner/plans/preview 400 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 phone breakpoint): the sheet is a BottomSheet of @mowdeck/ui with 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 (SpotRunBar with embedded, 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 MowerMap as insetBottom, 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 as insetTop, so the legend sits below it instead of under it (on desktop too).
    • Desktop keeps the side sheet.
  • 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, or state.connection === 'ble_bridge'; a connection that lists no ble_bridge link 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_DBM in $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 with position_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's transit.found, the same transit planner the gateway uses; no distance limit); the mower ready, activity === 'idle' or docked with capabilities.undock; no job, no drive session of this browser, no spot run; battery above 20 %; the mower online by its link heartbeat (isOnline of $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/plans with the same body, then the command spot_mow {plan, blades, confirm_blades, speed_mps, turn_style, spot} (spot is 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) while state.spot_run is 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 sends spot_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 a spot_stop over the API; unsupported skips the run part cleanly, and so do unmet conditions (the simulator starts docked).

Add mower and linked accounts

/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):

  1. 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 lacks can_manage_mowers on 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-accounts logs in to Mammotion and can take close to a minute, so the request uses fleetSlowApi (90 s deadline instead of 30 s) and the form shows a calm pending line. A refused login (422 linked-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.
  2. 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's last_sync_error.
  3. 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 /sites with the browser's time zone; a 409 shows at the input) and selects it.
  4. Add. One POST /linked-accounts/{id}/mowers; when everything was created, a toast and /mowers. Devices in failed stay 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. 409 mower-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 and live data

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.

Testing

  • Unit and component tests: Vitest with jsdom and Testing Library, files *.test.ts next to the code in apps/web/src/lib and in libs/mowdeck-ui/src/tests. pnpm --filter web test enforces 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 URL MOWDECK_E2E_BASE_URL (default http://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 in gate.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 project desktop (1280 x 800) runs desktop.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's GET /mowers/{id}/drive-sessions has it with client_stop), calibration.spec.ts (a simulated mower in real time, time_scale 1, 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 is started with 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 is skipped_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 answers accepted for 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 plan unlimited with the "no subscription limits" note; the operator finds the owner's account by search, assigns fleet with a reason after the empty reason was refused, and sees it in the history and in Entitlements' record; the plans page lists home and fleet and 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 sets NOTIFICATIONS_MOWING_MIN_SECONDS=10) camera.spec.ts (Phase 5 Task 25: the simulated mower's POST /camera/session answers a fake session with two cameras and no-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 (writes camera-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; writes camera-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") and connection.spec.ts (the add-mower form offers the kinds of GET /connection-kinds; for cloud a wrong password shows the field error, the right one links a conn-<run>@fake.mowdeck.test account, the device list shows Luba-FAKE0001 online, Luba-FAKE0002 offline and RTK-FAKE0001 greyed 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 /mowers with 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 them registered_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; a ble_bridge mower 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: no ble_bridge in the capabilities, a 503 from Fleet or unsupported from the gateway). The stack runs the Device-Gateway with the fake Mammotion cloud (GATEWAY_FAKE_MAMMOTION_CLOUD=true in stack.env): any *@fake.mowdeck.test address logs in with fake-cloud-password, other passwords are refused (422 linked-account-login-failed), and the account owns Luba-FAKE0001 (online, model LUBA 2 AWD 5000X, IoT id fakeiot0001), Luba-FAKE0002 (offline, fakeiot0002) and RTK-FAKE0001 (not a mower). CI runs test in the job e2e-web and uploads the report and the Compose logs when it fails. operator.spec.ts needs an operator admin, which only Identity's CLI creates: grantPlatformAdmin in tests/support.ts runs docker 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_FILE overrides the default infra/compose/docker-compose.yml of the checkout; MOWDECK_E2E_PLATFORM_ADMIN=off skips the spec, e.g. against a remote stack). The suite needs the overrides in tests/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 to infra/compose/.env (later lines win).

  • Screenshots for pull requests: screens writes login, 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) and inbox at phone and desktop size, plus connection-cloud-phone (the linked account connect form with a missing password) and connection-ble-desktop (the generated connection form with a validation message), schedules-phone, schedule-edit-desktop, schedule-runs-phone mower-map-rtk-desktop (the RTK heat layer of the last 24 h; the simulated mower always reports fixed, so the cells are green) and mower-map-coverage-desktop (the coverage layer with the missed spots), health-phone, health-desktop and drive-phone, drive-desktop-dark (a live session with the stick held). operator.spec.ts writes operator-accounts-desktop, operator-plan-sheet-phone (the assign sheet with fleet chosen) and account-plan-phone (the plan section with billing disabled). camera.spec.ts writes camera-phone (the full-screen camera with the test pattern). The test fleet screenshots writes fleet-map-desktop, fleet-map-phone (three simulated mowers at two sites, placed with the simulator options origin_lat and origin_lon), sites-phone and reports-desktop.

CSP rules

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 static style="" attributes in HTML; the build fails when one appears. Set dynamic styles through Svelte's style: 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 to img-src from its configuration (see Map), and the Agora domains of the live camera, added only with MOWDECK_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 with style="" attributes or HTML strings.
  • Code that must run before the app (the theme) goes into an external file under static/ like theme-init.js.
  • The service worker never handles /api/ requests.
  • vite.config.ts sets assetsInlineLimit: 0, so no asset becomes a data: URL in a stylesheet.

Mirrored from docs/dev/web-app.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.

MowDeck documentation

Clone this wiki locally