Phases - lukislp/mowdeck GitHub Wiki

Phases and gates

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

Phase 1 acceptance criterion

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.

Phase 2 acceptance criterion

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.

Progress

  • Task 1 (monorepo skeleton, Compose infrastructure with Traefik, NATS, PostgreSQL, OpenFGA, docs, CI): done.
  • Task 2 (foundation libraries mowdeck-contracts and mowdeck-service-kit, ADR 0009): done.
  • Task 3 (control library mowdeck-control: MowerConnection interface, MowerController with the safety rules, simulated mower, demo CLI; contracts MowerCapabilities and MowerMap; ADR 0012): done.
  • Task 4 (service template templates/service with 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-bootstrap Compose job, pluggable require_permission in 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_bin downsampling, position track, command result timeline, mower directory from Fleet events and fleet.mowers.list, retention job under an advisory lock, telemetry.view in the permission catalogue; ADR 0017): done.
  • Task 9 (Realtime service: WebSocket /api/v1/realtime/ws with the token in the first message, can_view per subscription re-checked on identity.grants.changed, every 60 s and on token refresh, per-instance fan-out without queue groups, coalesced states and slow consumer close, connection limits; contracts mowdeck_contracts.v1.realtime; the Phase 1 acceptance test tests/e2e with uv run poe e2e and the CI job e2e; ADR 0018): done.
  • Task 10 (web app foundation: pnpm workspace with the SvelteKit 2 SPA apps/web served by the Compose service web at /, the design system libs/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; Identity ui-areas, cookie-only refresh and reuse grace; map and capabilities snapshots from the Device-Gateway stored by Telemetry; the Playwright gate tests/e2e-web with the CI job e2e-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) and ble_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 script scripts/try_real_mower.py for 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 with SKIP LOCKED, backoff and notifications.sent; Web Push (VAPID, pywebpush 2.5.0) and ntfy channels as mowdeck.notification_channels plug-ins; inbox and settings API; ADR 0022): done, the PWA service worker follows with the web app.
  • Task 11 (map: MowerMap in libs/mowdeck-ui on 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 of mowdeck_control.geo with 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, notificationclick and pushsubscriptionchange with the WebPushPayload; module notifications with 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 the ui_area /notifications and 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 new GET /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 enables simulated,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.

Phase 3

  • 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 event telemetry.blade_hours.threshold; Notifications adds the critical theft alarms mower_lifted, mower_left_dock_unexpectedly and geofence_exit (bypass quiet hours, 10 min cooldown, acknowledge silences an hour) and the reminder blade_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 as ManualDrive commands, 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 integration mowdeck as 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 actions mowdeck.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 (WeatherProvider port, static provider for tests and offline use) using site coordinates, the map origin or the last position; ticker on every replica with FOR UPDATE SKIP LOCKED; starts and returns to the dock through the new Fleet request fleet.commands.issue on behalf of the schedule owner, whose can_control is re-checked at every firing (auto-disable after repeated skips); Fleet gains fleet.targets.resolve, site coordinates and origin/schedule_id on command records; events scheduler.run.recorded and scheduler.schedule.disabled; ADR 0024): done.

  • Task 17 (schedules and RTK signal map in the web app: module schedules (the Scheduler announces the ui_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 from POST /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; MowerMap RTK 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; Playwright schedules.spec.ts with 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]/drive with the Joystick of libs/mowdeck-ui (pointer and arrow keys, 10 % dead zone, samples at the server's max_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 learned startDrive, sendFrame (client-side rate limit), stopDrive and 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" with can_manage_mowers); the inbox acknowledges theft alarms (POST /inbox/{id}/ack) and shows the reported position (/mowers/[id]/map forwards to the mower page); Playwright drive.spec.ts and health.spec.ts; no backend change): done.

Phase 4

  • Task 20 (Helm chart for cloud deployments, multi-arch images and the release workflow: umbrella chart infra/helm/mowdeck with 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.example names, secrets as _FILE files, required secrets or existingSecret, read-only non-root pods, probes on /healthz and /readyz, optional HPA and PDB), migration Jobs as pre-install,pre-upgrade hooks with an external PostgreSQL or per-revision Jobs with the in-cluster CloudNativePG Cluster and one Database per service, wait-for-schema and wait-for-model init containers, the authorization bootstrap Job, NATS (JetStream) and OpenFGA as optional official subcharts, two Ingresses with the Compose path routes, ServiceMonitor; values-homelab.yaml and values-cloud.yaml; infra/helm/check.sh (lint, template, kubeconform -strict, required-secret checks) in the new CI job helm, the drift test test_helm.py, the kind install test infra/helm/kind-test.sh with the Phase 1 acceptance test as helm-kind.yml (manual and tags); release.yml for 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 tag v0.1.0 is cut separately.

Phase 3 acceptance criterion

Against the Compose stack with the simulated mower:

  1. A schedule fires at its time and starts the mower (run started, Fleet command executed with origin=scheduler).
  2. The rain pause holds a run with the static weather provider set to rain (run skipped_rain).
  3. 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.
  4. Joystick: a drive session from the web app moves the mower, and it stands still within about one second after the frames cease.
  5. Health: blade and mowing hours grow after a mowing job and the health page shows them with the daily bars.
  6. Theft alarm: the rules for mower_lifted, mower_left_dock_unexpectedly and geofence_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).

Phase 4

  • 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 with active, trial, grace and expired computed from the clock, the installation switch MOWDECK_ENTITLEMENTS_BILLING_ENABLED (off: every account unlimited), the feature catalogue from the module descriptors (GET /features), GET /me, the public GET /plans and the operator endpoints on platform:main#can_manage_plans (plans, accounts, manual assignment with history and entitlements.plan.changed); the core NATS request entitlements.check and the EntitlementsClient in mowdeck-service-kit (snapshot cache, no answer: the local billing switch decides); enforcement in Fleet (max_mowers, max_sites, max_groups, control on every command but the emergency stop, fleet.bulk_commands), Scheduler (schedules, max_schedules), Identity (max_users, the plan claim), Realtime (manual_drive on drive_start) and Notifications (notifications.push); Identity's operator endpoint GET /identity/platform/accounts and the CLI grant-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 the ui_area /fleet) with the fleet map (FleetMap in libs/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; module reports (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 serves GET /api/v1/telemetry/reports/summary (day totals of all visible mowers in one call); the simulator options origin_lat and origin_lon; the phone navigation keeps five targets with a "more" sheet; Playwright fleet.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); 409 plan-limit and 403 feature-not-in-plan shown in words with the limit and the current count or the feature (problem toasts on every create action, problemMessage everywhere else); a banner on the mower pages while the plan is in grace or expired (only viewing and the emergency stop remain); module operator (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 public GET through the gateway with latency; no other metrics); no mower data anywhere in the console; Playwright operator.spec.ts with the operator admin granted through docker 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/billing behind the Compose profile billing and Helm services.billing.enabled=false (not started when billing is disabled); PaymentProvider with StripeProvider (Stripe Python SDK 16, API version 2026-09-30.endive, api_base for stripe-mock) and FakeProvider; 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_tax for Stripe Tax), the customer portal, prices as data in infra/billing/prices.yaml with BILLING_PRICE_IDS; POST /webhooks/stripe with the signature verified over the raw body and every event id processed once (webhook_events, claim with a lease, replays answer duplicate, 500 on failure so Stripe retries, stale events ignored), Stripe status to plan status (trialing trial, active active, payment problems grace until the end of the last paid period + MOWDECK_BILLING_GRACE_DAYS, then expired) set in Entitlements over the new internal request entitlements.plan.set (source=billing, history, plan.changed), invoices, billing.subscription.changed; GET /status (public), GET /subscription, POST /checkout, POST /portal, GET /invoices owner only (can_manage_billing), the webhook rate limited in Traefik; the web app probes GET /billing/status and shows plans with prices, Upgrade, Manage subscription and invoices on the account page only when it answers; legal placeholder pages /legal/imprint|terms|privacy linked from the login page with WEB_LEGAL_ENABLED; stripe/stripe-mock pinned 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.

Phase 4 acceptance criterion

Against the Compose stack with simulated mowers:

  1. With billing disabled (the default) every account has the plan unlimited: GET /api/v1/entitlements/me says so and no limit or feature gate refuses anything.
  2. With ENTITLEMENTS_BILLING_ENABLED=true and the default plan home, an owner registers four mowers and the fifth answers 409 plan-limit (limit 4, current 4); an operator admin (granted with python -m identity.cli grant-platform-admin) assigns fleet to the account and the fifth registration succeeds without a restart.
  3. A plan without manual_drive refuses drive_start (drive_not_allowed); an account in grace can still send the emergency stop to every mower but cannot start one or register another.
  4. 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.
  5. 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.
  6. 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 is operator.spec.ts: with the stack's default the account page shows the plan unlimited with 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, fleet assigned with a reason and shown in the history, home and fleet on 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 (source billing).
  • Item 6: Task 20, infra/helm/check.sh in CI and the kind install test infra/helm/kind-test.sh (workflow helm-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 billing and the fake provider on 2026-10-10: GET /billing/status enabled with both plans and prices, POST /checkout returns a URL, a signed customer.subscription.updated (past_due) switched the account to grace (source billing) and a later active restored it, a replay answered duplicate; 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.

Phase 5

  • Task 23 (mowing patterns and the Planner, backend: library libs/mowdeck-patterns (Apache-2.0, Shapely) with the MowPattern plug-in interface and the entry point group mowdeck.patterns, the models Area, 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 patterns stripes, diagonal, checkerboard, spiral, border_fill, contour, auto_aligned, cells, alternating, edge_only and remow, chain(), coverage() and gaps(), preview() and a golden preview of the simulator's front area; contracts SpotMowPlan (encoded segments, SHA-256 hash, optional native job), StartMow.spot_plan and the track query tracks.positions.query (PositionTrackRequest/PositionTrackReply, answered by Telemetry); MowerController runs spot plans only with capabilities.custom_paths or as their native job, the simulator follows them (custom_paths=True), the Mammotion adapter refuses them without a native job; Fleet gates spot_mowing, limits the plan size, refuses spot plans in bulk commands and records plan_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 the DEVICE stream; Compose, Helm, CI images, OpenAPI and generated types, the plan feature spot_mowing on home and fleet; 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 (provider agora or fake, app id, channel, short-lived token, uid, cameras, AES-256-GCM2 encryption, expiry, 4G quota), CameraSessionRequest / CameraSessionReply on the core NATS subject gateway.mower.<id>.camera.session (outside the DEVICE stream); the optional connection capability CameraCapable in mowdeck-control; MammotionCloudConnection.get_camera_session() over PyMammotion's get_stream_subscription and get_video_resource, capability camera=cloud for cloud mowers (not the Luba 1), None over BLE, a fake session from the simulator; the Device-Gateway answers for the mowers of its shard with the command account rule and mowdeck_device_gateway_camera_sessions_total{outcome}; Fleet POST /api/v1/fleet/mowers/{id}/camera/session (can_view, plan feature camera on home and fleet, audit mower.camera_opened, 409 camera-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 for fake, the SDK loaded lazily for agora, muted, camera picker, token renewal, data note with the 4G quota, errors in words); the CSP adds the Agora domains only with MOWDECK_WEB_CAMERA_ENABLED=true (cameraEnabled in /config.json); owner test scripts/try_real_mower.py --camera; Playwright camera.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, SpotRunStatus and MowerState.spot_run; SpotMowRun in mowdeck-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 through handle() as ManualDrive with correlation_id = spot:<run_id>; only emergency_stop, spot_stop and set_light while a run drives); the simulator's report rate (state_period_s) and position noise (position_noise_m); the Device-Gateway's accepted and final results and the plan as the path snapshot; Fleet's gates, bulk refusal, size limit and plan_hash audit; 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-check and --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 undock with the optional protocol UndockCapable (leave_dock(); the simulator drives 1 m out of the dock, the Mammotion adapter sends PyMammotion's leave_dock over the cloud); the pure transit planner mowdeck_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); SpotMowRun phases undocking, waiting_rtk (90 s, undock_failed or rtk_lost), transit (blades off, at most 0.3 m/s, geofence = drivable space, published as a transfer path, own share of the time limit) and returning (SpotMow.return_to_dock, default true, after done and healthy aborts, never after a stop); the 10 m approach limit is gone; docked starts allowed with undock (controller rule 12 and spot_start_problem); the Planner's preview returns the transit (transit in 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): DriveCalibrate and DriveCalibrationRun (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's PUT /mowers/{id}/drive-calibration and applied to every spot run (max_linear 1.0, turn_max for about 20 °/s, gains scaled by the yaw rate, dead zones); the progress-based time limit (stalled after 60 s without progress, cap max(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 with operate_on_device carrying max_run_speed (bridge option manual_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, else invalid), 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) for SpotMow.turn_style and the mower's own MowerState.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_point to arc without reverse; the run follows shaped turns at 15 °/s at most, rotates in place only with in_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 with arc and multi_point). Open: the turning radius and turn rate on the LUBA.

Phase 5 acceptance criterion (proposed)

  1. 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.
  2. After the run, "re-mow gaps" shows the gaps computed from the position track and starts a remow plan for them; the coverage afterwards is above 95 % of the mowable area.
  3. 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.
  4. 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_paths gets only plans with a native job, everything else is refused before it reaches the mower.
  5. With billing enabled a plan without spot_mowing gets 403 feature-not-in-plan from the Planner and from Fleet.
  6. Live camera: criterion of the camera task (stream path, latency, access control).

Phase 6

  • Task 27 (Vision service, AI assist with human approval: services/vision with the VisionProvider port, 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 feature ai_recover_hosted, own providers behind ai_recover; POST /mowers/{id}/recover (recover or find_dock, snapshots from the app or capture_photo through the gateway), proposals analysing to proposed/rejected_by_ai/failed, vision.proposal.created into the Notifications kind vision_proposal for users with can_drive; approval on the approver's behalf through fleet.drive.start (new origin: vision on drive sessions), frames at 5 Hz with ttl_ms 1000 and blades off, abort on emergency stop, sensor stop, new error, geofence, missing state and cancellation, verification, up to three rounds, find_dock ending in return_to_dock; MowerState.drive_recoverable and the controller rule for it; the simulator's inject_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).

Phase 6 acceptance criterion (proposed)

Against the Compose stack with simulated mowers:

  1. A simulated mower with stuck_after_seconds gets stuck while mowing; POST /recover yields a proposal, the owner gets the vision_proposal notification with a link to it, the approval drives the steps (a drive session with origin: vision in Fleet) and the proposal ends recovered with the mower idle again.
  2. An answer with a risk flag (for example fake/people) is rejected_by_ai and never drives; an emergency stop during an execution aborts it at once and no further frame is sent.
  3. 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 is 409 vision-provider-missing.
  4. 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.

Milestones after Phase 6

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, null or blank resets, 60 characters), MowerOut.area_names and the event fleet.mower.area_names_changed (AreaNamesChanged); one display rule in mowdeck_contracts.v1.area_display_name, the web app's areaLabel/areaNamer and 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 on gateway.cloud.discover (contracts CloudDiscoverRequest/CloudDiscoverReply/DiscoveredDevice) and shares one PyMammotion session per account between its mowers and the discovery (CloudSessionPool in mowdeck-control-mammotion); cloud mowers reference a linked account and a device (mowers.linked_account_id, mowers.iot_id, migration 0008); one physical mower (IoT id) in one MowDeck account installation-wide (409 mower-already-registered, registered_elsewhere in the device list); registrations carry no secrets any more (the gateway fetches them on fleet.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 cloud MOWDECK_FAKE_MAMMOTION_CLOUD for development and connection.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 with set_car_manual_light where PyMammotion's is_support_fill_light allows it, the simulator keeps a light state and takes dark photos with night; the controller allows set_light in every activity, also during an error and a drive session; Fleet gates it with can_control and the plan features control and camera; the web app's camera light switch in the camera panel with the reported state; Vision retakes a photo darker than MOWDECK_VISION_DARK_FRAME_THRESHOLD with the light on, switches it off after the analysis and marks the proposal light_used; the Home Assistant switch switch.<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 (cameraStates of the stream token request through PyMammotion's all_cameras, connection option all_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 a placement, 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; ImageIn gains camera, side and captured_at; a pair is analysed left first with the hint to use the parallax for a rough distance and never invent one, the proposal records stereo and frames (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. packetLossRate is a cumulative fraction since the join (the 32.8 % was history, not the current state), currentPacketLossRate is the last 400 ms; end2EndDelay is 0 in the rtc profile, so the panel falls back to receiveDelay, transportDelay, RTT and 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 with WITH_CAPTURE=true: Playwright, Chromium headless shell, the pinned Agora Web SDK; Compose profile capture, Helm services.visionCapture) joins the camera's Agora channel headless and returns one JPEG (POST /api/v1/capture, contracts CameraSnapshotRequest, 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, plan ai_recover) on the AI provider page; the durable consumer vision-auto starts 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 records trigger = error and the code, Notifications sends vision_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 no drive_recoverable yet).
  • Native apps for iPhone and Android (ADR 0036, guide): done in code. Capacitor 8 shells in apps/mobile (app id app.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 plugin MowDeckShell stores it natively and the native code sets it as server.url at 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 module apps/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 channel apns (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 scripts provision.sh, build-ios.sh, sign-and-install.sh (devicectl, over SSH too); build-android.sh and the android job of release.yml that attaches mowdeck-<version>.apk to each release (needs the keystore secrets). Open (needs the Mac and the phones): the first provision.sh with 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 stores wifi_rssi, mnet_rssi and blades_on (migration 0007). Consecutive blades-on positions are joined by a straight swath when at most MOWDECK_TELEMETRY_HEATMAP_JOIN_SECONDS (30 s) and MOWDECK_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 sends cancel_job, waits for the work mode to leave working/paused, then return_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)" from CommandView.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 capability path_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 sends StartMow{area_ids, pattern} (the contracts' MowPattern); the command list shows "Mähen starten · Streifen 45° · 1 Randrunde" from CommandView.pattern. Settings are remembered per mower in Fleet (PUT /mowers/{id}/mow-defaults, MowerOut.mow_defaults, migration 0009, audit mower.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's fleet.targets.resolve carries TargetMower.mow_pattern and the Scheduler sends a MowPattern-shaped pattern (defaults, else stripes with one border lap, with the schedule's angle as angle_deg; before, it sent angle, 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 before start_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's path_hash as text, progress {index, direction}), the device snapshot device.mower.<id>.path (MowerPathSnapshot, cleared with path: null when the job ends) and the preview request gateway.mower.<id>.path.preview (PathPreviewGatewayRequest/PathPreviewReply); mowdeck-control protocols MowPathSource and PathPreviewCapable with 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's MowPathSaga (skip_planning, so the mower is never planned again; 90 s, three attempts, also after a reconnect) and maps the progress from ub_path_hash/now_index, and plans a preview without start_job; the Device-Gateway publishes the path on change (progress at most every 5 s) and answers previews (75 s); Telemetry serves GET /mowers/{id}/path (404 no-path); Fleet POST /mowers/{id}/path-preview (can_control, feature control, 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 of now_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_at and publishes connected = false when the link ends, so a docked mower that reports rarely stays online in the web app.
  • Release v0.1.0 through 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.

⚠️ **GitHub.com Fallback** ⚠️