Phases - lukislp/mowdeck GitHub Wiki
Each phase ends with a gate: the next phase starts only when the criterion is met. After roughly 8 to 12 weeks the own app should replace the Mammotion app for the owner household.
| Phase | Name | Content |
|---|---|---|
| 1 | Foundation | Monorepo, Compose stack, Identity (accounts, sub-users, roles), OpenFGA authorization, Fleet, Device-Gateway with a simulated mower, Telemetry, Realtime, tenant-isolation tests, spike document |
| 2 | MVP | Real mower control through PyMammotion, status and map, start/pause/dock, cut height and speed, connection choice, notifications, web app (PWA) |
| 3 | Comfort | Home Assistant (HACS) integration, schedules, rain pause, health history, joystick driving, RTK signal map, theft alarm, more users and themes |
| 4 | Fleet & Subscription | Sites, groups, fleet map, bulk commands, reports and fleet API, Billing, Entitlements, operator console, Kubernetes/Helm |
| 5 | Spot & Camera | Spot mowing with pluggable mowing patterns, re-mowing missed spots, live camera |
| 6 | AI | Vision service: Ai Recover and Find Dock |
docker compose up starts all foundation services (Gateway/Traefik, Identity, OpenFGA, Fleet, Device-Gateway, Telemetry, Realtime, NATS, PostgreSQL); a user registers, creates a sub-user with the Operator role, registers a simulated mower, sends start/stop and sees live status over WebSocket; a user from a foreign account is rejected everywhere, backed by tests; the spike document exists.
docker compose up serves the web app at / (service web) next to the API. In the installed PWA (German by default, English available), an owner registers, registers a mower, sees it docked with battery, RTK fix and connection, starts a chosen zone, follows the mower live on the map with its driven route, changes blade height and speed while it mows, pauses, resumes and sends it back to the dock; a Viewer sees status and the emergency stop but no start control; a user of another account sees nothing; push notifications arrive for "finished", "stuck", "battery low" and "offline". All of this is backed by the Playwright suite tests/e2e-web (CI job e2e-web) against the simulated mower; it covers the gate steps 1 to 11 of the web app design (section 8.2): registration, mower registration, live status, areas chosen on the map and started, the live map (the mower chevron moves, the driven route has at least two points), blade height while mowing, return to dock, the Viewer, the foreign account, language and theme, logout; plus notifications (preferences and an ntfy target saved, the test notification answered 202 then 429, the unread badge, a finished simulated job in the inbox) and the connection forms (kinds from Fleet, cloud validation, a ble_bridge mower whose noise key is shown as set and never echoed). Web Push delivery through a real push service is checked by hand on the owner's phones (headless Chromium has no push service). The adapter package mowdeck-control-mammotion is ready for the hardware test: it passes the MowerConnection contract tests, the connection kind can be switched in the app, and a documented hardware test checklist exists. The hardware test itself (the household LUBA replaced the Mammotion app for one week) closes the phase.
- Task 1 (monorepo skeleton, Compose infrastructure with Traefik, NATS, PostgreSQL, OpenFGA, docs, CI): done.
- Task 2 (foundation libraries
mowdeck-contractsandmowdeck-service-kit, ADR 0009): done. - Task 3 (control library
mowdeck-control:MowerConnectioninterface,MowerControllerwith the safety rules, simulated mower, demo CLI; contractsMowerCapabilitiesandMowerMap; ADR 0012): done. - Task 4 (service template
templates/servicewith Copier, Device-Gateway service with the simulated mower, Fleet registration contracts, Compose integration, Docker build in CI; ADR 0013): done. - Task 5 (authorization: OpenFGA model as code with CLI tests in CI, library
mowdeck-authz,authz-bootstrapCompose job, pluggablerequire_permissionin the kit; ADR 0014): done. - Task 6 (Identity service: accounts, sub-users, roles with anti-escalation, sessions with refresh rotation, API tokens, audit log, JWKS and key rotation, OpenFGA tuples; library
mowdeck-auth; Device-Gateway verifies tokens; identity events and the authorization cache invalidation; ADR 0015): done. - Task 7 (Fleet service: sites with a hidden default site, groups, mowers, hierarchy tuples, the Device-Gateway registry from Fleet instead of a seeded mower, commands checked per command kind with command records and results, bulk commands; command API contracts;
mowdeck_authz.fastapi.authorize(); ADR 0016): done. - Task 8 (Telemetry service: latest state, history with de-duplicated heartbeats and
date_bindownsampling, position track, command result timeline, mower directory from Fleet events andfleet.mowers.list, retention job under an advisory lock,telemetry.viewin the permission catalogue; ADR 0017): done. - Task 9 (Realtime service: WebSocket
/api/v1/realtime/wswith the token in the first message,can_viewper subscription re-checked onidentity.grants.changed, every 60 s and on token refresh, per-instance fan-out without queue groups, coalesced states and slow consumer close, connection limits; contractsmowdeck_contracts.v1.realtime; the Phase 1 acceptance testtests/e2ewithuv run poe e2eand the CI jobe2e; ADR 0018): done. - Task 10 (web app foundation: pnpm workspace with the SvelteKit 2 SPA
apps/webserved by the Compose servicewebat/, the design systemlibs/mowdeck-ui, generated API types (tools/ts-codegen, OpenAPI export), login with cookie-only refresh, mower list and detail with live status, commands, blade height and speed, area picker on a schematic map, settings, sub-users, API tokens, account, German and English, service worker; Identityui-areas, cookie-only refresh and reuse grace; map and capabilities snapshots from the Device-Gateway stored by Telemetry; the Playwright gatetests/e2e-webwith the CI jobe2e-web; ADR 0020, ADR 0021): done.
Phase 1 gate: met (verified against the local stack on 2026-10-09; CI runs the same suite in the job e2e). tests/e2e/test_phase1_acceptance.py runs the criterion above against docker compose up --wait through Traefik (registration, a sub-user with the Operator role, a simulated mower, start and dock with live status over the WebSocket, a foreign account rejected with 404 and denied subscriptions, a Viewer refused with 403 but allowed the emergency stop, an idle socket held for 90 s); the spike document is docs/spikes/0001-ble-bridge-and-pymammotion.md.
Phase 2:
- Task 13a (real mower adapter
libs/mowdeck-control-mammotion, GPL-3.0, on PyMammotion 0.10.9:cloud(Mammotion MQTT) andble_bridge(ESPHome Bluetooth Proxy) connections with state, capabilities, map and job commands, manual driving over BLE only; Fleet validates the new connection options and masks secrets; the Device-Gateway image includes the adapter; owner scriptscripts/try_real_mower.pyfor the spike run; ADR 0019): done, hardware run pending (build the bridge first: Bluetooth bridge guide). - Task 12a (Notifications service, backend: rules over the mower states and connection events for mowing finished, stuck, battery low, offline and online, optional job started, with hysteresis, cooldown and de-duplication; recipients are subscribers re-checked with
can_view; quiet hours per time zone; DE and EN templates; outbox worker withSKIP LOCKED, backoff andnotifications.sent; Web Push (VAPID,pywebpush2.5.0) and ntfy channels asmowdeck.notification_channelsplug-ins; inbox and settings API; ADR 0022): done, the PWA service worker follows with the web app. - Task 11 (map:
MowerMapinlibs/mowdeck-uion Leaflet 1.9.4, metric grid in local metres by default and optional raster tiles per installation through/config.json(MOWDECK_WEB_MAP_TILES, rendered with the CSP at container start), TypeScript port ofmowdeck_control.geowith golden vectors, pure layer model, areas, no-go zones, paths, dock, mower chevron with heading, driven route from Telemetry plus live points (1 h, today, 24 h) with age fading and an RTK tint, start areas chosen by tapping the map, full-screen map on phones with the emergency stop in view; gate step 11; ADR 0023): done. - Task 12b (notifications in the PWA: service worker
push,notificationclickandpushsubscriptionchangewith theWebPushPayload; modulenotificationswith the bell in the navigation and an unread badge (polled every 60 s and on focus); inbox with kind badges in the state colours, mark read on open and mark all read; settings for kinds, mowers, quiet hours with the device time zone and channels; Web Push on this device (permission, VAPID subscription, the account's subscriptions with "this device", HTTPS and iOS guidance); ntfy target with a write-only token; test notification with the 429 cooldown; Notifications announces theui_area/notificationsand keeps a stored ntfy token unless it is cleared; OpenAPI export and generated types for Notifications): done. - Task 13b (connection forms: Fleet's option schemas per kind as data (
fleet.domain.connections) drive both the validation and the newGET /api/v1/fleet/connection-kinds; 422 answers name each option (#/connection_options/<key>); the web app generates the add-mower and mower settings forms from the endpoint (alternatives as a segmented choice, write-only secrets marked "set", advanced options, client checks mirroring Fleet, server field errors on the inputs), so a new adapter kind needs no UI change; the example environment enablessimulated,cloud,ble_bridge): done.
Phase 2 gate: all tasks done, gate: Playwright steps 1 to 11 green plus the notification and connection form specs (pnpm --filter e2e-web test, 16 tests, verified against the local stack on 2026-10-09). Open: Web Push delivery on Android Chrome and the installed iOS PWA (manual, needs HTTPS) and the hardware week with the household LUBA.
-
Task 16 (health history, theft alarm and manual drive channel, backend: Telemetry derives blade hours, mowing hours, distance, battery cycles, charge sessions and error counts incrementally in its state consumer (UTC days, idempotent on redelivery) with
GET /mowers/{id}/health,/health/daily,/battery, the blade reset and the eventtelemetry.blade_hours.threshold; Notifications adds the critical theft alarmsmower_lifted,mower_left_dock_unexpectedlyandgeofence_exit(bypass quiet hours, 10 min cooldown, acknowledge silences an hour) and the reminderblade_maintenance; manual driving over the Realtime WebSocket (drive_start,drive_frame,drive_stop) with sessions opened and recorded by Fleet (fleet.drive.start/stop,GET /mowers/{id}/drive-sessions), frames rate limited and published asManualDrivecommands, the Device-Gateway controller as the only safety authority; ADR 0026, ADR 0027): done, the UI follows. -
Task 15 (Home Assistant integration, since moved to its own repository
lukislp/mowdeck-hacs, HACS-ready custom integrationmowdeckas a pure client of the public API: config flow with URL and API token, reauth and options, lawn mower, sensors, binary sensors, buttons, numbers, area select, GPS tracker and the actionsmowdeck.start_mow,emergency_stop,set_blade_height,set_speed, live states over the Realtime WebSocket with polling fallback and a Repairs issue for refused tokens, diagnostics, DE and EN; own uv project on Home Assistant 2026.10.0 with tests and hassfest in that repository's CI; ADR 0025, guide): done. -
Task 14 (Scheduler service, backend: weekly and every-N-days schedules for a mower or a group in the site's time zone with DST rules, windows, manual skip, pattern rotation by angle; rain pause with Open-Meteo (
WeatherProviderport, static provider for tests and offline use) using site coordinates, the map origin or the last position; ticker on every replica withFOR UPDATE SKIP LOCKED; starts and returns to the dock through the new Fleet requestfleet.commands.issueon behalf of the schedule owner, whosecan_controlis re-checked at every firing (auto-disable after repeated skips); Fleet gainsfleet.targets.resolve, site coordinates andorigin/schedule_idon command records; eventsscheduler.run.recordedandscheduler.schedule.disabled; ADR 0024): done. -
Task 17 (schedules and RTK signal map in the web app: module
schedules(the Scheduler announces theui_area/schedules) with the list (recurrence in words, next run with countdown, rain verdict, last outcome, enabled switch, skip next, run now with "respect the rain pause"), the editor (mower or group, weekday chips or every N days, window, areas picked on the map in order, mowing angle and rotation list, rain pause thresholds, time zone override; client checks mirroring the API, 422 on the fields) with the preview (next firings, verdict, forecast strip) and the runs page with outcome badges and command links; edit controls per target fromPOST /me/permissions, read-only otherwise; the site location (time zone and coordinates, from the map origin) on the mower settings; the next schedule on the mower page;MowerMapRTK signal map (route points binned into 0.5, 1 or 2 m cells by worst or majority fix, legend, layer toggle) with today, 7 days and 30 days on the mower page and the shares of fixed, float and none; Playwrightschedules.spec.tswith the static weather provider and its development endpoint (tests/e2e-web/stack.env)): done. -
Task 18 (joystick, health page, theft alarm acknowledge and the Phase 3 acceptance in the web app: drive page
/mowers/[id]/drivewith theJoystickoflibs/mowdeck-ui(pointer and arrow keys, 10 % dead zone, samples at the server'smax_rate_hz), speed limit 25/50/100 %, two-step blade switch (arm, then switch; disarmed on every stop and error), session strip (session, frames sent and acknowledged per second, dead-man, rate, time left), Stop and the emergency stop, a live map that follows the mower, screen wake lock; the session ends when the page is left, the tab is hidden or the socket drops; the Realtime client learnedstartDrive,sendFrame(client-side rate limit),stopDriveand the drive events; health page/mowers/[id]/health(blade hours since the change against the interval, totals, 30-day bar strips, battery of the last 24 hours, errors by code, blade changes and "blades replaced" withcan_manage_mowers); the inbox acknowledges theft alarms (POST /inbox/{id}/ack) and shows the reported position (/mowers/[id]/mapforwards to the mower page); Playwrightdrive.spec.tsandhealth.spec.ts; no backend change): done.
- Task 20 (Helm chart for cloud deployments, multi-arch images and the release workflow: umbrella chart
infra/helm/mowdeckwith a template per service on shared named templates (Deployments, the Device-Gateway as a StatefulSet with the shard from the pod index, ConfigMaps with the Compose settings under the.env.examplenames, secrets as_FILEfiles, required secrets orexistingSecret, read-only non-root pods, probes on/healthzand/readyz, optional HPA and PDB), migration Jobs aspre-install,pre-upgradehooks with an external PostgreSQL or per-revision Jobs with the in-cluster CloudNativePGClusterand oneDatabaseper service,wait-for-schemaandwait-for-modelinit containers, the authorization bootstrap Job, NATS (JetStream) and OpenFGA as optional official subcharts, two Ingresses with the Compose path routes, ServiceMonitor;values-homelab.yamlandvalues-cloud.yaml;infra/helm/check.sh(lint, template, kubeconform-strict, required-secret checks) in the new CI jobhelm, the drift testtest_helm.py, the kind install testinfra/helm/kind-test.shwith the Phase 1 acceptance test ashelm-kind.yml(manual and tags);release.ymlfor tags: amd64 and arm64 images with SBOMs to GHCR, the chart as an OCI artifact, a GitHub release with generated notes; the per-service Helm value stubs removed from the services and the template; ADR 0029, Helm operations, Releases): done, the first tagv0.1.0is cut separately.
Against the Compose stack with the simulated mower:
- A schedule fires at its time and starts the mower (run
started, Fleet commandexecutedwithorigin=scheduler). - The rain pause holds a run with the static weather provider set to
rain(runskipped_rain). - Home Assistant (the HACS integration) shows the mower with its state and starts it. Verified by hand in Task 15 and documented in the Home Assistant guide; not part of the web gate.
- Joystick: a drive session from the web app moves the mower, and it stands still within about one second after the frames cease.
- Health: blade and mowing hours grow after a mowing job and the health page shows them with the daily bars.
- Theft alarm: the rules for
mower_lifted,mower_left_dock_unexpectedlyandgeofence_exit(critical, past quiet hours, cooldown, acknowledge silences an hour) are covered by the Notifications tests; the inbox acknowledges them.
Items 1, 2, 4 and 5 are the Playwright suite tests/e2e-web (CI job e2e-web): schedules.spec.ts (1, 2), drive.spec.ts (4: the pad held forward for 2 s moves the mower, after letting go the speed is 0 within 2 s, the session is recorded by Fleet with client_stop) and health.spec.ts (5: blade, mowing hours and distance above zero, today's bar, a blade change recorded by the owner, no blade reset for a Viewer). Item 6 is services/notifications/tests (rules, quiet hours, cooldown, acknowledge) and the inbox store tests.
Phase 3 gate: met locally, CI runs e2e-web (verified against the local stack on 2026-10-10: pnpm --filter e2e-web test, the phone and desktop projects, all green).
- Task 19 (Entitlements service and plan enforcement, without Billing: plans as data seeded from
infra/entitlements/plans.yaml(unlimited,home,fleet; upsert by key, an API edit survives restarts until the file entry changes), account plans withactive,trial,graceandexpiredcomputed from the clock, the installation switchMOWDECK_ENTITLEMENTS_BILLING_ENABLED(off: every accountunlimited), the feature catalogue from the module descriptors (GET /features),GET /me, the publicGET /plansand the operator endpoints onplatform:main#can_manage_plans(plans, accounts, manual assignment with history andentitlements.plan.changed); the core NATS requestentitlements.checkand theEntitlementsClientinmowdeck-service-kit(snapshot cache, no answer: the local billing switch decides); enforcement in Fleet (max_mowers,max_sites,max_groups,controlon every command but the emergency stop,fleet.bulk_commands), Scheduler (schedules,max_schedules), Identity (max_users, theplanclaim), Realtime (manual_driveondrive_start) and Notifications (notifications.push); Identity's operator endpointGET /identity/platform/accountsand the CLIgrant-platform-admin; ADR 0028): done, Billing and the operator console UI follow. - Task 21 (fleet management and reports in the web app: module
fleet(Fleet announces theui_area/fleet) with the fleet map (FleetMapinlibs/mowdeck-ui: every visible mower live in WGS84 with an adaptive metric grid without tiles, markers in their state colour with names, the areas of loaded maps, filters by site and group, a legend with counts per state, mowers without a position) and "pause all" and "all to dock" with a confirmation sheet and the outcome per mower; sites (default marked, time zone, address, coordinates from a mower's map origin; create, edit, delete, move mowers, bulk commands per site) and groups (site or cross-site, members, bulk commands, a schedule for the group); site and groups on the mower settings; role assignments at account, site, group or mower level on the users page (Identity's existing API); the mower list in sections per site with quick filters; modulereports(Telemetry announces/reports): mowing and blade time, distance, jobs, faults by code and availability per site and period (today, 7 days, 30 days, custom) per mower and in total, CSV export (UTF-8 with BOM, German or English dialect, ISO dates) and "copy as table"; Telemetry counts jobs and online time per mower and UTC day (migration 0005) and servesGET /api/v1/telemetry/reports/summary(day totals of all visible mowers in one call); the simulator optionsorigin_latandorigin_lon; the phone navigation keeps five targets with a "more" sheet; Playwrightfleet.spec.ts): done. - Task 22 (operator console and plan display in the web app: a plan section on the account page for every account (
GET /entitlements/me: plan name, status badge with the end of a trial or grace period, the limits as gauges with the current use from the lists the app loads, the features as chips, and with billing disabled a note that this installation has no subscription limits); 409plan-limitand 403feature-not-in-planshown in words with the limit and the current count or the feature (problem toasts on every create action,problemMessageeverywhere else); a banner on the mower pages while the plan is in grace or expired (only viewing and the emergency stop remain); moduleoperator(shield icon) shown only when Identity's operator endpoint answers 200 (GET /identity/platform/accounts?limit=1, no token claim exists): accounts (Identity's accounts joined with Entitlements' plans, paged, search over the installation, detail with plan history and an "assign plan" sheet with plan, status, optional end date and the required reason), plans (list and editor for limits and features from the catalogue, create by key) and system (billing switch, the plan marked default, counts, modules, and whether each service answers a publicGETthrough the gateway with latency; no other metrics); no mower data anywhere in the console; Playwrightoperator.spec.tswith the operator admin granted throughdocker compose exec identity python -m identity.cli grant-platform-admin): done, no backend change. - Task 24 (Billing with a Stripe adapter, optional per installation and off by default:
services/billingbehind the Compose profilebillingand Helmservices.billing.enabled=false(not started when billing is disabled);PaymentProviderwithStripeProvider(Stripe Python SDK 16, API version2026-09-30.endive,api_basefor stripe-mock) andFakeProvider; one Stripe customer per account (metadata.account_id), Checkout in subscription mode (client_reference_id, metadata, the plan's trial for a first subscription, tax id and billing address for business accounts,automatic_taxfor Stripe Tax), the customer portal, prices as data ininfra/billing/prices.yamlwithBILLING_PRICE_IDS;POST /webhooks/stripewith the signature verified over the raw body and every event id processed once (webhook_events, claim with a lease, replays answerduplicate, 500 on failure so Stripe retries, stale events ignored), Stripe status to plan status (trialingtrial,activeactive, payment problems grace until the end of the last paid period +MOWDECK_BILLING_GRACE_DAYS, then expired) set in Entitlements over the new internal requestentitlements.plan.set(source=billing, history,plan.changed), invoices,billing.subscription.changed;GET /status(public),GET /subscription,POST /checkout,POST /portal,GET /invoicesowner only (can_manage_billing), the webhook rate limited in Traefik; the web app probesGET /billing/statusand shows plans with prices, Upgrade, Manage subscription and invoices on the account page only when it answers; legal placeholder pages/legal/imprint|terms|privacylinked from the login page withWEB_LEGAL_ENABLED;stripe/stripe-mockpinned in the CI python job; ADR 0031, Billing operations with the operator checklist): done, verified with stripe-mock and signed fixtures; a run against Stripe test mode is pending the owner's keys.
Against the Compose stack with simulated mowers:
- With billing disabled (the default) every account has the plan
unlimited:GET /api/v1/entitlements/mesays so and no limit or feature gate refuses anything. - With
ENTITLEMENTS_BILLING_ENABLED=trueand the default planhome, an owner registers four mowers and the fifth answers 409plan-limit(limit 4, current 4); an operator admin (granted withpython -m identity.cli grant-platform-admin) assignsfleetto the account and the fifth registration succeeds without a restart. - A plan without
manual_driverefusesdrive_start(drive_not_allowed); an account ingracecan still send the emergency stop to every mower but cannot start one or register another. - Fleet operations: sites and groups with their own maps, the fleet map of all mowers, bulk commands with the per-mower outcome, reports per site with CSV export, scoped API tokens for the fleet API.
- Billing with Stripe (Checkout and customer portal, signed webhooks processed once) sets plans and statuses through Entitlements; the operator console lists accounts, plans and system health without any mower data.
- The Helm chart installs the stack on Kubernetes.
Verification:
- Items 1 to 3: Task 19, the Entitlements service tests and the enforcement tests of Fleet, Scheduler, Identity, Realtime and Notifications (
uv run pytest services/...) and the Compose run recorded there. The web side of item 1 isoperator.spec.ts: with the stack's default the account page shows the planunlimitedwith the "no subscription limits" note; the assignment of item 2 is done in the operator console by an operator admin granted with the CLI, and the history shows it. - Item 4:
fleet.spec.ts(sites, groups, the fleet map, bulk commands with the outcome per mower, reports with CSV export); the scoped API tokens are Identity's existing token grants (services/identity/tests). - The operator console part of item 5:
operator.spec.ts(the module appears only for an operator admin, a normal owner gets the "operators only" notice; accounts listed with plan and plan status,fleetassigned with a reason and shown in the history,homeandfleeton the plans page, the services answering on the system page; no mower data). The console has no billing view; Billing's state shows in the plan history (sourcebilling). - Item 6: Task 20,
infra/helm/check.shin CI and the kind install testinfra/helm/kind-test.sh(workflowhelm-kind.yml, manual and on tags) with the Phase 1 acceptance test. - Item 5, Billing with Stripe: Task 24. Verified with stripe-mock (
MOWDECK_TEST_STRIPE_MOCK_URL: customer, Checkout, portal, subscription and price requests validated against Stripe's API definition, also through the API) and signed webhook fixtures (services/billing/tests: signature valid, invalid, outdated and tampered; a replayed event id processed once; the status mapping to Entitlements; out-of-order events), and in an isolated Compose run with--profile billingand the fake provider on 2026-10-10:GET /billing/statusenabled with both plans and prices,POST /checkoutreturns a URL, a signedcustomer.subscription.updated(past_due) switched the account tograce(sourcebilling) and a lateractiverestored it, a replay answeredduplicate; without the profile the web app shows nothing billing related (operator.spec.ts). Real Stripe test mode is pending the owner's keys (Billing operations).
Phase 4 gate: met except Billing (deferred by decision) (verified against the local stack on 2026-10-10: pnpm --filter e2e-web test, the phone and desktop projects including operator.spec.ts, all green).
Billing (item 5): implemented in Task 24 and verified with stripe-mock and signed fixtures; the end-to-end run against Stripe test mode follows once the owner adds test keys.
- Task 23 (mowing patterns and the Planner, backend: library
libs/mowdeck-patterns(Apache-2.0, Shapely) with theMowPatternplug-in interface and the entry point groupmowdeck.patterns, the modelsArea,MowerSpec,MowPlan, the validation chain (inside the area minus the safety margin, no blades on through no-go zones, obstacles or holes, blades off on transfers, speeds, turning radius), the patternsstripes,diagonal,checkerboard,spiral,border_fill,contour,auto_aligned,cells,alternating,edge_onlyandremow,chain(),coverage()andgaps(),preview()and a golden preview of the simulator'sfrontarea; contractsSpotMowPlan(encoded segments, SHA-256 hash, optional native job),StartMow.spot_planand the track querytracks.positions.query(PositionTrackRequest/PositionTrackReply, answered by Telemetry);MowerControllerruns spot plans only withcapabilities.custom_pathsor as their native job, the simulator follows them (custom_paths=True), the Mammotion adapter refuses them without a native job; Fleet gatesspot_mowing, limits the plan size, refuses spot plans in bulk commands and recordsplan_hash; the Planner service (services/planner): patterns, previews and plans for a mower or a raw map (map area, spot in local metres or WGS84), execution mode per mower, coverage and gaps, templates per account, snapshot directory from theDEVICEstream; Compose, Helm, CI images, OpenAPI and generated types, the plan featurespot_mowingonhomeandfleet; ADR 0030): done, the UI follows. - Task 25 (live camera through the Mammotion cloud connection, ADR 0032: the browser joins the Agora channel directly with the Agora Web SDK, no media passes through MowDeck; contracts
CameraSession(provideragoraorfake, app id, channel, short-lived token, uid, cameras, AES-256-GCM2 encryption, expiry, 4G quota),CameraSessionRequest/CameraSessionReplyon the core NATS subjectgateway.mower.<id>.camera.session(outside theDEVICEstream); the optional connection capabilityCameraCapableinmowdeck-control;MammotionCloudConnection.get_camera_session()over PyMammotion'sget_stream_subscriptionandget_video_resource, capabilitycamera=cloudfor cloud mowers (not the Luba 1),Noneover BLE, afakesession from the simulator; the Device-Gateway answers for the mowers of its shard with the command account rule andmowdeck_device_gateway_camera_sessions_total{outcome}; FleetPOST /api/v1/fleet/mowers/{id}/camera/session(can_view, plan featurecameraonhomeandfleet, auditmower.camera_opened, 409camera-unsupported, 503 without an answer); the web app's camera panel on the mower page and the full-screen page/mowers/[id]/camera(test pattern forfake, the SDK loaded lazily foragora, muted, camera picker, token renewal, data note with the 4G quota, errors in words); the CSP adds the Agora domains only withMOWDECK_WEB_CAMERA_ENABLED=true(cameraEnabledin/config.json); owner testscripts/try_real_mower.py --camera; Playwrightcamera.spec.ts): done with the simulator; the real stream waits for the owner's run against the LUBA. - Spot mowing by MowDeck-driven paths (ADR 0039), core: contracts
SpotMow,SpotStop,SpotRunStatusandMowerState.spot_run;SpotMowRuninmowdeck-control(geofence from the map inset 0.3 m minus buffered no-go zones, pure pursuit with 0.7 m lookahead, turn in place above 35°, speed governor, at most 0.3 m/s, RTK and stale-pose pauses with an abort after 30 s, cross-track and turn-direction aborts, run-time limit, zero frame and blades off at every end) behind controller rule 12 (frames throughhandle()asManualDrivewithcorrelation_id = spot:<run_id>; onlyemergency_stop,spot_stopandset_lightwhile a run drives); the simulator's report rate (state_period_s) and position noise (position_noise_m); the Device-Gateway'sacceptedand final results and the plan as the path snapshot; Fleet's gates, bulk refusal, size limit andplan_hashaudit; the Mammotion adapter's drive mode of the refresh loop (report window plus undebounced polls about once a second while a drive session is open); owner script--turn-checkand--spot-dry-run. Done against the simulator, with the web spot mode (a circle or a drawn shape on the map, or a missed spot from the coverage heatmap; sheet with blades off by default and a double confirmation for blades on, a bottom sheet on phones; run view with STOP; see the web app guide); open: the owner's turn check and 1 m dry run on the LUBA (report interval, latency, turn direction). - Spot runs from the dock (ADR 0041): capability
undockwith the optional protocolUndockCapable(leave_dock(); the simulator drives 1 m out of the dock, the Mammotion adapter sends PyMammotion'sleave_dockover the cloud); the pure transit plannermowdeck_control/transit.py(drivable space of areas inset 0.3 m, path corridors ±0.5 m and a dock apron minus buffered zones; graph of path vertices, area and zone corners, portals and connectors; A* across several paths and areas;no_route, maximum 300 m);SpotMowRunphasesundocking,waiting_rtk(90 s,undock_failedorrtk_lost),transit(blades off, at most 0.3 m/s, geofence = drivable space, published as atransferpath, own share of the time limit) andreturning(SpotMow.return_to_dock, default true, afterdoneand healthy aborts, never after a stop); the 10 m approach limit is gone; docked starts allowed withundock(controller rule 12 andspot_start_problem); the Planner's preview returns the transit (transitin the request,transit{found, route, ...}in the answer) so the web app's "route found" condition matches the gateway's check, and the preview draws the route; web run bar and conditions for the new states and reasons; owner script--spot-dry-run --from-dock. Done against the simulator; open: the undocking and transit check on the LUBA (see the ADR). - Drive calibration (ADR 0042), after the first real spot run (about 0.05 m/s and 1 °/s instead of the assumed 0.5 m/s, time limit hit):
DriveCalibrateandDriveCalibrationRun(forward at 0.3 to 1.0, turns at ±0.3 to ±1.0, fit of full-scale speed, yaw rate and dead zones, room check and 1.5 m budget, blades off) behind controller rule 13; the result stored as link options through Fleet'sPUT /mowers/{id}/drive-calibrationand applied to every spot run (max_linear1.0,turn_maxfor about 20 °/s, gains scaled by the yaw rate, dead zones); the progress-based time limit (stalledafter 60 s without progress, capmax(20 min, 3 × estimate)); the vendor's speed setting for manual driving (drive_speed_setting,SetDriveSpeed,MowerState.drive_speed_mps; Mammotion over the cloud) with the proportional rescale; the web app's calibration block and speed slider; owner script--calibrate,--save,--get-speed,--set-speed; the Mammotion drive session now opens withoperate_on_devicecarryingmax_run_speed(bridge optionmanual_drive_speed_mps, else the mower's speed setting, at most 1.0 m/s), which it never sent with the blades off before. Done against the simulator. The first hardware run gave unusable numbers (reports only every 0.8 to 1 s, the mower 1 to 2 s late, steps with two reports counted as standing): the calibration now measures 6 s steps over their steady window only (without the first 1.5 s, at least 4 reports in 3 s, elseinvalid), pauses 2 s between steps, drops inconsistent turn pairs and outliers, needs two points per axis, and asks the adapter for reports every 0.5 s while it runs (ReportRateCapable); the simulator has a drive response delay. Open: the calibration table and a calibrated dry run on the LUBA. - Turn styles (ADR 0043), so spot runs do not rotate an all-wheel-drive mower on the turf at every lane end:
TurnStyle(in_place,arc,multi_point) forSpotMow.turn_styleand the mower's ownMowerState.turn_style(Mammotion:turning_mode, read only),MowerCapabilities.drive_reverse(the Bluetooth bridge and the simulator: yes),SpotSegment.reverse; the Planner shapes stripe lane ends as arcs, bulb turns (lopsided at the edge lanes) or three-point turns inside the area and the spot run's geofence, pulls them back into the lanes when needed and falls back to turning in place with a warning,multi_pointtoarcwithout reverse; the run follows shaped turns at 15 °/s at most, rotates in place only within_place, where no shaped turn applies or at the geofence's edge, and drives reverse legs backwards with the blades off; the web sheet's "Wenden" select, distinct turn segments in the plan preview and a joystick without reverse on links that cannot reverse. Done against the simulator (stripes at 1 Hz with noise: no in-place rotation witharcandmulti_point). Open: the turning radius and turn rate on the LUBA.
- In the web app an owner draws a spot inside an area of a simulated mower (or picks a whole area), chooses a pattern or a chain from the registry with a settings form built from its JSON Schema, sees the preview (paths, duration, area, warnings, execution mode) and starts it; the simulated mower mows along the plan with the blades off on every transfer.
- After the run, "re-mow gaps" shows the gaps computed from the position track and starts a
remowplan for them; the coverage afterwards is above 95 % of the mowable area. - A template ("2 border laps + diagonal") is saved, listed and started; a Viewer sees previews but cannot start or save; a user of another account sees nothing.
- A plan that would leave the area, cut through a no-go zone or turn tighter than the mower can is refused with the validation chain's reasons; a mower without
custom_pathsgets only plans with a native job, everything else is refused before it reaches the mower. - With billing enabled a plan without
spot_mowinggets 403feature-not-in-planfrom the Planner and from Fleet. - Live camera: criterion of the camera task (stream path, latency, access control).
- Task 27 (Vision service, AI assist with human approval:
services/visionwith theVisionProviderport, a LiteLLM adapter for any vision-capable model family configured by a model string (Anthropic, OpenAI, Gemini, Mistral, Ollama, OpenAI-compatible endpoints; JSON schema or prompt mode, one validation retry, retries of transient errors, tokens and LiteLLM's cost estimate per request) and a rule based fake; per-user providers set in the web app (keys write-only, AES-GCM at rest) and an optional installation model (MOWDECK_VISION_DEFAULT_*) behind the plan featureai_recover_hosted, own providers behindai_recover;POST /mowers/{id}/recover(recover or find_dock, snapshots from the app orcapture_photothrough the gateway), proposalsanalysingtoproposed/rejected_by_ai/failed,vision.proposal.createdinto the Notifications kindvision_proposalfor users withcan_drive; approval on the approver's behalf throughfleet.drive.start(neworigin: visionon drive sessions), frames at 5 Hz withttl_ms1000 and blades off, abort on emergency stop, sensor stop, new error, geofence, missing state and cancellation, verification, up to three rounds,find_dockending inreturn_to_dock;MowerState.drive_recoverableand the controller rule for it; the simulator'sinject_stuck,stuck_after_seconds(dev only) and still images; the web app's AI assist sheet, AI provider settings page and the hosted model on the operator's System page; ADR 0033).
Against the Compose stack with simulated mowers:
- A simulated mower with
stuck_after_secondsgets stuck while mowing;POST /recoveryields a proposal, the owner gets thevision_proposalnotification with a link to it, the approval drives the steps (a drive session withorigin: visionin Fleet) and the proposal endsrecoveredwith the mower idle again. - An answer with a risk flag (for example
fake/people) isrejected_by_aiand never drives; an emergency stop during an execution aborts it at once and no further frame is sent. - A user configures an own provider (key write-only), tests it, and the next request uses it; without an own provider the installation model is used only when the plan has
ai_recover_hosted, otherwise the answer is409 vision-provider-missing. - A real vision model (Anthropic or a local Ollama model) answers a recover request on the simulator's image with a valid assessment; tokens and cost are recorded.
Verification: Task 27, the Vision service tests (uv run pytest services/vision, PostgreSQL and NATS integration included), vision.spec.ts, and the isolated Compose run recorded with the task. Item 4 needs an API key (or an Ollama installation) and is open until the owner provides one.
Items the owner asked for after the first tests with the real LUBA 2 AWD X; each becomes a task with tests and docs when it is picked up.
- Area renaming in MowDeck: custom names per mower and area id stored in Fleet, used on the map, in lists, pickers, schedules, the planner, reports and the Home Assistant entities; the mower keeps its own names. Done: Fleet's table
mower_area_names(migration 0007),GET /mowers/{id}/area-names(can_view),PUT /mowers/{id}/area-names/{area_id}(can_control,nullor blank resets, 60 characters),MowerOut.area_namesand the eventfleet.mower.area_names_changed(AreaNamesChanged); one display rule inmowdeck_contracts.v1.area_display_name, the web app'sareaLabel/areaNamerand the Home Assistant coordinator: the custom name, else the mower's own name, else "Bereich N" / "Area N" in map order (replacing "Bereich 7683"); renaming inline in the area list and the picker and from an edit button on the map labels; the Home Assistant area select shows the resolved names. Scheduler, Planner, Notifications and Reports only pass area ids through (no area names in their texts), so they needed no change. - Rework of "add mower" together with cloud device discovery (done, ADR 0034: linked Mammotion accounts in Fleet with the password encrypted (
MOWDECK_CREDENTIAL_ENCRYPTION_KEY),/api/v1/fleet/linked-accounts(link, list, re-read, delete, device list, bulk registration); the Device-Gateway logs in and lists the devices ongateway.cloud.discover(contractsCloudDiscoverRequest/CloudDiscoverReply/DiscoveredDevice) and shares one PyMammotion session per account between its mowers and the discovery (CloudSessionPoolinmowdeck-control-mammotion); cloud mowers reference a linked account and a device (mowers.linked_account_id,mowers.iot_id, migration0008); one physical mower (IoT id) in one MowDeck account installation-wide (409mower-already-registered,registered_elsewherein the device list); registrations carry no secrets any more (the gateway fetches them onfleet.mowers.credentials), failed vendor logins are throttled (429), the encryption key rotates (MOWDECK_CREDENTIAL_ENCRYPTION_KEY_PREVIOUS); existing cloud mowers keep working until the owner re-adds them; the web app's add-mower flow (kind, account, device list, names and sites, several at once) and the settings page "Verbundene Konten"; the fake Mammotion cloudMOWDECK_FAKE_MAMMOTION_CLOUDfor development andconnection.spec.ts). Open: the device list, the online flag and several mowers on one session against the real Mammotion cloud. - Mower light (done: contracts
SetLight(set_light,on),MowerCapabilities.light,MowerState.light_on; the Mammotion adapter reads the camera's headlamp after connect (get_car_light(ids=1126)) and switches it withset_car_manual_lightwhere PyMammotion'sis_support_fill_lightallows it, the simulator keeps a light state and takes dark photos withnight; the controller allowsset_lightin every activity, also during an error and a drive session; Fleet gates it withcan_controland the plan featurescontrolandcamera; the web app's camera light switch in the camera panel with the reported state; Vision retakes a photo darker thanMOWDECK_VISION_DARK_FRAME_THRESHOLDwith the light on, switches it off after the analysis and marks the proposallight_used; the Home Assistant switchswitch.<mower>_light). The light is the headlamp, not the side LEDs (the first version switched the side LEDs; the owner's hardware test showed "on" with nothing lit, and the camera light was meant); the side LEDs may become a second control later. Open: the headlamp switch on the owner's LUBA 2 AWD X. - Stereo images for AI assist: the LUBA 2 AWD X has a left and a right front camera. Done in two steps. v1, both front cameras in the camera panel: the Mammotion adapter asks the cloud for every camera (
cameraStatesof the stream token request through PyMammotion'sall_cameras, connection optionall_cameras, default true; before, only the front left camera published as uid 1, which is why both picker buttons showed the same picture), the session's cameras carry aplacement, and the panel offers "Beide" / "Both": left and right side by side (stacked when narrow), each its own subscription and element with its name and live resolution and frame rate, "Keine Daten von dieser Kamera" when a camera delivers nothing within 8 s, a notice when both tiles show the same picture; per-track diagnostics; the simulator has a front left and a front right camera (the right pattern mirrored). v2, stereo frames in Vision: the browser captures both frames of the same moment (within 200 ms) when both views are live;ImageIngainscamera,sideandcaptured_at; a pair is analysed left first with the hint to use the parallax for a rough distance and never invent one, the proposal recordsstereoandframes(migration 0004) and the web app shows "Stereo" with both images; the per-user preference "Stereo-Bilder, wenn verfügbar" (default on,/vision/me/preferences); the light handling is unchanged. Open: the right camera and both streams on the owner's LUBA (uid 2 publishing, the 4G allowance with two streams). Later, optional: depth estimation from the pair after a one-time calibration so a proposal can state a rough obstacle distance. - AI mapping (KI-Kartierung): while the mower drives (a dedicated survey run or normal mowing), Vision analyses camera frames together with the RTK position and heading and builds a map of what is where in the garden — trees, beds, fences, furniture, the station, recurring obstacles — stored as named landmarks with positions on the mower map, reviewed and corrected by the owner, and given to the model as context for recovery proposals. Stereo frames help with distance. Owner request 2026-10-11, not started.
- Camera diagnostics units: done.
packetLossRateis a cumulative fraction since the join (the 32.8 % was history, not the current state),currentPacketLossRateis the last 400 ms;end2EndDelayis 0 in the rtc profile, so the panel falls back toreceiveDelay,transportDelay,RTTand labels the source. - Automatic AI analysis on mower errors (opt-in per user): done, ADR 0035. Server-side camera frames: the capture worker
vision-capture(the Vision image built withWITH_CAPTURE=true: Playwright, Chromium headless shell, the pinned Agora Web SDK; Compose profilecapture, Helmservices.visionCapture) joins the camera's Agora channel headless and returns one JPEG (POST /api/v1/capture, contractsCameraSnapshotRequest, token, concurrency limit, timeouts); the Device-Gateway uses it for camera-capable adapters without an own photo (MOWDECK_CAPTURE_URL), PyMammotion 0.10.9 has no snapshot command. Automatic analysis: the opt-in "Automatische KI-Analyse bei Fehlern" (user_preferences.auto_analysis,GET/PUT /api/v1/vision/me/preferences, planai_recover) on the AI provider page; the durable consumervision-autostarts an analysis when a mower enters a recoverable error (stuck, blocked by an obstacle, outside its area, wheel slip; never lift, battery or blade errors) with a cooldown of 10 min per mower, at most 20 per mower and day, the opted-in user's provider and quota, a photo required; the proposal recordstrigger = errorand the code, Notifications sendsvision_auto_proposal("KI-Vorschlag für <Mäher>") with the deep link, the proposal page says "Automatisch ausgelöst durch Fehler", nothing moves without an approval. Open (needs the hardware): a frame of the LUBA 2 AWD X through the worker (codec decodable by the headless shell, time to the first frame, whether the worker's viewer uid conflicts with an open browser, whether the front-only stream request switches off the second camera of an open stereo view), the Mammotion error codes as the firmware reports them (sign, which codes appear when stuck), and executing an approved proposal on the cloud connection (no manual drive over the cloud, the adapter sets nodrive_recoverableyet). - Native apps for iPhone and Android (ADR 0036, guide): done in code. Capacitor 8 shells in
apps/mobile(app idapp.mowdeck.mobile) that load the web app from the owner's server: a bundled first-launch screen asks for the server address (checked through/manifest.webmanifest), the shell's own pluginMowDeckShellstores it natively and the native code sets it asserver.urlat start, so the refresh cookie stays first-party and server updates reach the apps at once; offline page, "change server" in the settings,mowdeck://deep links, icons and splash screens from the web app's icon, light/dark after the system. The web app's bridge moduleapps/web/src/lib/native/(no Capacitor dependency in the web app) turns new inbox items into local notifications, sets the icon badge, routes notification taps and, on iOS, registers with APNs; inside the app the Web Push panel gives way to an app panel. Notifications without Firebase: the new Notifications channelapns(token-based, HTTP/2,MOWDECK_APNS_*, device tokens at/api/v1/notifications/me/push/apns, removed on logout and when Apple reports them gone, migration 0005, Compose secret folder and Helm values) for the iPhone; ntfy for Android in the background. Mac scriptsprovision.sh,build-ios.sh,sign-and-install.sh(devicectl, over SSH too);build-android.shand theandroidjob ofrelease.ymlthat attachesmowdeck-<version>.apkto each release (needs the keystore secrets). Open (needs the Mac and the phones): the firstprovision.shwith the paid team, the Xcode and Gradle builds, the install, APNs delivery with the owner's key, the remote bridge on both platforms, and the keystore secrets in the repository. - Coverage and signal heatmaps: API done. Telemetry's
GET /api/v1/telemetry/mowers/{id}/heatmap?layer=coverage|rtk|wifi|mnet&from=&to=&cell=bins the position track into cells of 0.5, 1 or 2 m in the map's local frame (default the last 7 days, at most 200 000 rows newest first, at most 20 000 cells, coarser beyond): the cut share per cell from blades-on positions widened to the model's cutting width (MowerCapabilities.cutting_width_mm, Mammotion 400 mm for Luba 1 and LUBA 2, 320 mm for Yuka, simulator 300 mm, else 400 mm) with the missed areas of the map as polygons, the share of fixed RTK samples, and the median Wi-Fi and mobile signal in dBm. The position track storeswifi_rssi,mnet_rssiandblades_on(migration 0007). Consecutive blades-on positions are joined by a straight swath when at mostMOWDECK_TELEMETRY_HEATMAP_JOIN_SECONDS(30 s) andMOWDECK_TELEMETRY_HEATMAP_JOIN_METRES(10 m) apart, because the cloud reports a mowing LUBA only every 1 to 20 s (the gateway refreshes every 20 s). The map layers and the list of missed spots are in the web app (web app guide). Open: tuning both limits against the LUBA 2 AWD X's real tracks (straight joins cut corners at turns). - Dock with or without ending the job (done:
ReturnToDock.end_job, default false; the Mammotion adapter sendscancel_job, waits for the work mode to leave working/paused, thenreturn_to_dock; the mower page asks "Zur Station, Aufgabe abbrechen" or "Nur zur Station" while a job runs, is paused or in error; the command list shows "Zur Station (Aufgabe beendet)" fromCommandView.end_job). - Pattern settings at the start (owner request 2026-10-11: the start mowed only the inside with fixed settings, no edges): done. The start sheet has a "Mähen" section under the area list: the native pattern (only what the mower reports in
native_patterns: Streifen, Diagonal, Schachbrett, Nur Rand, Zufallswinkel), the angle (0 to 180 degrees, hidden for edge only and random angle), border laps 0 to 4 (default 1, so the edges are mowed), the path spacing within the model's range (new capabilitypath_spacing_range_mm, Mammotion from the model table), a summary line and the current blade height and speed with a jump to their steppers. It sendsStartMow{area_ids, pattern}(the contracts'MowPattern); the command list shows "Mähen starten · Streifen 45° · 1 Randrunde" fromCommandView.pattern. Settings are remembered per mower in Fleet (PUT /mowers/{id}/mow-defaults,MowerOut.mow_defaults, migration 0009, auditmower.mow_defaults_changed) when "Als Standard merken" is on; without saved defaults the sheet uses stripes at 0 degrees with one border lap. Schedules start from the same defaults: Fleet'sfleet.targets.resolvecarriesTargetMower.mow_patternand the Scheduler sends aMowPattern-shaped pattern (defaults, else stripes with one border lap, with the schedule's angle asangle_deg; before, it sentangle, which the adapters ignored). The schedule form still edits only the angle and the rotation list. Later (owner request 2026-10-13): blade height and speed steppers in the start sheet (start-blade,start-speed, the model's ranges, pre-filled with the reported values); the start sends only the changed setpoints beforestart_mow; they stay mower setpoints, not mow defaults. - Computed mow path (owner request 2026-10-11: show the route the mower planned for the chosen areas and pattern, and the progress along it while mowing): done. Contracts
MowPath(polylines in local metres,kinds,points_total, the vendor'spath_hashas text,progress {index, direction}), the device snapshotdevice.mower.<id>.path(MowerPathSnapshot, cleared withpath: nullwhen the job ends) and the preview requestgateway.mower.<id>.path.preview(PathPreviewGatewayRequest/PathPreviewReply);mowdeck-controlprotocolsMowPathSourceandPathPreviewCapablewith the controller rule (no preview while a job runs) and the simulator's own route; the Mammotion adapter fetches the running job's route in the background with PyMammotion'sMowPathSaga(skip_planning, so the mower is never planned again; 90 s, three attempts, also after a reconnect) and maps the progress fromub_path_hash/now_index, and plans a preview withoutstart_job; the Device-Gateway publishes the path on change (progress at most every 5 s) and answers previews (75 s); Telemetry servesGET /mowers/{id}/path(404no-path); FleetPOST /mowers/{id}/path-preview(can_control, featurecontrol, 409/503/504); the web app draws it on the map (accent line, mowed part solid, the rest dimmed, legend), the toggle "Mähpfad" in the heat strip, and "Pfad berechnen" in the start sheet with the preview on the map. Open (needs the hardware): the route fetch over MQTT on the LUBA 2 AWD X while mowing and its duration over 4G, the per-line meaning ofnow_index, and whether the route hash of the planning confirmation equals the hash of the line list. - Dual link and firmware updates (ADR 0038): done. A cloud mower may carry an optional Bluetooth bridge (an ESPHome Bluetooth proxy) as an extra link, added in the mower settings with a scan through the bridge; cloud first, with Bluetooth for manual driving, spot runs and faster positions while mowing; the bridge panel shows the signal, the bridge firmware and an "outdated" badge. Firmware check and update of the mower from the web app with progress, only in the dock with at least 70 % battery. Open: the update tracker relies on vendor reports that were verified only partly on the LUBA (see the ADR).
- Online status from a link heartbeat (ADR 0040): done. The Device-Gateway republishes the latest state every 10 s with
heartbeat_atand publishesconnected = falsewhen the link ends, so a docked mower that reports rarely stays online in the web app. - Release
v0.1.0through the release workflow once the owner gives the go.
Mirrored from docs/phases.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.