Repository navigation
ADR 0033 Vision Assisted Recovery With Approval
Accepted (2026-10-10). Amended by ADR 0035: frames of a real camera through the capture worker, automatic analyses on errors.
The project plan lists "Ai Recover und Find Dock: Kamerabild an Vision-Modell, Freigabe per Push" for the KI phase, a later Vision service, and "Wetter, Karten, KI: je ein Adapter-Port". Its safety rules apply to every automated movement: a dead-man switch (a drive command expires after about a second), blades off during transfers, an emergency stop that is always visible, and sensor stops that are never bypassed. The drive channel of ADR 0026 already turns a session recorded by Fleet (fleet.drive.start, can_drive) into ManualDrive frames that the Device-Gateway's controller checks one by one (ADR 0012).
A vision model can look at a snapshot of a stuck mower and suggest how to free it, but it can be wrong, it can miss a child or a pond, and it costs money per request. The owner decided that the service must work with any vision-capable model (cloud or local), that every user configures a provider in the web app, and that an installation may additionally offer its own model.
A separate Vision service asks a vision model for a short maneuver, a user with can_drive approves it, and the service drives it through the existing drive channel with blades off; the controller stays the safety authority.
-
Provider port.
VisionProvider.analyse(images, context, task) -> Assessment. The real adapter uses LiteLLM with a model string (anthropic/claude-sonnet-5-5,openai/gpt-5-mini,gemini/gemini-2.5-flash,mistral/pixtral-large-latest,ollama/llava,openai/<model>plus a base URL for any OpenAI-compatible endpoint); a rule basedfake/...provider serves development, demos and tests. A native SDK adapter can be added behind the same port. The answer is structured output: a JSON schema asresponse_formatwhere the model supports it (Claude gets its native structured outputs), otherwise the schema in the prompt and a tolerant parser; either way Pydantic validatessituation,confidence, at most six steps{linear, angular in -1..1, seconds <= 3},rationale,riskandabort_reasons, with one retry on an invalid answer. The schema has no blade field. Every call is accounted (model, family, tokens, LiteLLM's cost estimate, latency, outcome) inprovider_requests. -
Who pays. A user's own provider (model string, base URL, write-only API key and extra headers encrypted with AES-GCM under
MOWDECK_VISION_SECRETS_KEY, user and account as associated data) needs the plan featureai_recover. The installation default (MOWDECK_VISION_DEFAULT_*, off by default) needsai_recover_hosted. Resolution per request: own enabled provider, else the installation default when enabled and in the plan, else409 vision-provider-missing. The hosted feature is a sibling key, notai_recover.hosted: plan keys are hierarchical, so a plan listingai_recoverwould otherwise always grant the hosted model too. -
Cost control. A per-user daily cap of analyses (
MOWDECK_VISION_USER_DAILY_REQUESTS, both providers), a per-account daily cap for the hosted model, an hourly limit of provider tests, and429 vision-quotabeyond them. Images are stored only for the proposal's TTL (MOWDECK_VISION_PROPOSAL_TTL_SECONDS, default 300) unlessMOWDECK_VISION_KEEP_IMAGES_DAYS> 0; images and keys are never logged. -
Scene. Images come from the app (a snapshot of the camera panel's video, JSON base64 or multipart) or, without one, from the mower's gateway (
gateway.mower.<id>.photo, core NATS; an optionalPhotoCapture.capture_photo()ofmowdeck-control, implemented by the simulator from its map). TheSceneContextholds the last state, the map around the mower in the mower frame (forward and left metres) with the dock's distance and bearing, recent commands and earlier rounds; no names. -
Proposal lifecycle.
POST /mowers/{id}/recover(can_drive,ai_recover) creates a proposal:analysingthenproposed,rejected_by_ai(any risk flag, confidence belowMOWDECK_VISION_MIN_CONFIDENCE, no step) orfailed. A proposed one publishesvision.proposal.created; Notifications turns it intovision_proposalfor the account's users withcan_driveon the mower (Web Push and the inbox, deep link to the proposal). At most one open proposal per mower. -
Approval is mandatory.
POST /proposals/{id}/approve(can_drive, one approval, refused afterexpires_at) records the approver and drives the steps on the approver's behalf:fleet.drive.startwithon_behalf_ofthe approver and the neworigin: vision(Fleet checkscan_driveagain and records the session with its origin), then frames at 5 Hz withttl_ms1000 andblade_on = false, then a zero frame andfleet.drive.stop(completedoraborted).POST /rejectrejects a waiting proposal or cancels a running one. -
Abort conditions (checked between frames): an emergency stop by anyone (seen on
command.mower.<id>), a sensor stop (any error that is notdrive_recoverable, or the lift flag), a new error, another activity, a refused frame, no state for 3 s, leaving the geofence (mapped areas plusMOWDECK_VISION_GEOFENCE_MARGIN_M, or the dock surroundings; never a no-go zone), a cancellation. After an emergency or sensor stop no further frame is sent, not even a zero frame. Execution does not start without a fresh state, a map and a position inside the geofence. -
Stuck is not a sensor stop. A new optional
MowerState.drive_recoverablemarks errors that driving may clear (stuck, wheel slip); the controller admits drive frames only for those, so the plan's "sensor stops are never bypassed" still holds for lift, tilt and collision. -
Verification and rounds. After the steps the outcome is checked (no error, moved at least
MOWDECK_VISION_MIN_MOVE_Mor turned as planned):recoveredornot_recovered; up toMOWDECK_VISION_MAX_ROUNDS(3) rounds per assist session, each a fresh analysis with the earlier rounds in the context and a new approval.find_dockdrives only an approach and then sends a normalreturn_to_dockthroughfleet.commands.issue(originvision); it never drives into the dock. -
Audit. Proposals (requester, model, answer, context, approver, decisions, outcome, drive session, poses) and
proposal_steps(timestamps, frames, outcome) are kept; Fleet's drive sessions showorigin: vision.
- Nothing moves without a human decision; the controller's dead-man timer and blade rules apply to every frame, and a crashed Vision instance stops the mower within one second.
- Images go to the chosen model provider; for cloud models they leave the installation (the settings page says so), local models (Ollama, OpenAI-compatible endpoints) keep them inside.
- LiteLLM makes the image about twice as large as the other services (about 710 MB) and its import takes seconds; the service imports it lazily and warms it up in a thread. Its price map is the bundled one (no request at startup), so cost estimates follow the LiteLLM version.
- Each Vision instance follows telemetry, maps, commands and results of every mower (ephemeral consumers); fine for the expected fleet sizes, a shard filter can follow.
- The hosted model costs the operator money per analysis; the caps bound it, Billing does not meter it yet.
- Not covered yet: realtime push of proposal updates (clients poll), Web Push actions to approve from the notification itself.
The LUBA 2 AWD X has a front left and a front right camera, and the camera panel can show both side by side (ADR 0032, consequences). AI assist uses the pair:
-
Capture in the browser. With both front cameras live and the user's preference
stereo_frameson (per user and account,GET/PUT /vision/me/preferences, default on, a switch on the AI provider page), the web app grabs both frames of the same moment (two grabs started together; each in its ownrequestVideoFrameCallback, timed by the frame's receive time; a pair more than 200 ms apart is grabbed again, then only the left frame is sent). -
API.
ImageIngains optionalcamera,side(left,right) andcaptured_at; single-frame requests are unchanged. Two uploaded frames with both sides from two cameras withinMOWDECK_VISION_STEREO_MAX_SKEW_MS(500 ms) are a stereo pair; with the preference off only the left frame is analysed. -
Prompt. Both images, left first, each after a caption with its camera and capture time, plus
stereo: truein the context and an explicit hint: the left and right front camera of the same moment; use the parallax for a rough distance estimate; do not invent a distance when unsure (the camera spacing is not calibrated). -
Record.
proposals.stereoandproposals.frames(camera, side, capture time per image; migration 0004), both images stored for the proposal's lifetime;ProposalOut.stereoandframes; the web app shows "Stereo" and the two images side by side. The cost accounting already counts the tokens of both images (about twice the image share); the detail of the cost row saysimages=2 stereo=true. -
Light. Unchanged: the light is only switched for the service's own photo (a request without images), so
light_usedkeeps its meaning and a stereo request never switches it. - Later. Depth estimation from the pair after a one-time calibration (a stated obstacle distance) stays an optional later step; until then distances are the model's rough estimate at most.
Real Mammotion mowers have no still image command (PyMammotion 0.10.9 has none, see ADR 0035). The Device-Gateway now answers photo requests of camera-capable adapters with a frame from the capture worker (vision-capture, headless Chromium with the Agora Web SDK, optional), so Vision gets a picture without an open browser. Analyses can also start by themselves when a mower enters a recoverable error and a user opted in (trigger = error, notification vision_auto_proposal); the approval rules of this ADR apply unchanged.
A field report: a LUBA stood paused (a vendor trap error MowDeck did not map yet) in the same lawn as its dock, 15 m away. find_dock answered "not recommended": the model read drive_recoverable: false (it is false for every state without an error) as a sensor stop, one camera frame was unusable, the dock lay outside the 8 m map excerpt and the answer schema had no dock command at all, although a plain return to dock was the right call.
-
Action vocabulary. The answer gains
action:return_to_dock(the generic dock command of the mower control interface, ADR 0012; the mower drives home on its mapped areas and transfer paths by itself),drive(the steps as before) ornone. The prompt namesreturn_to_dockthe lowest-risk option and prefers it whenever it applies, also forrecover. - Route check (pure, manufacturer independent). Return to dock applies when the mower stands in a mapped area (or on a transfer path) that is the dock's area (the dock within 3 m of its boundary) or is linked to it by mapped transfer paths, and no lift, tilt or other safety stop (an error that is not drive recoverable, or one naming a lift, tilt, blade or motor fault) is active and the mower is not docked, charging, returning, offline or updating.
-
Context.
dock_route(mower area, dock area, route, distance, applicable and why), transfer paths in the map excerpt,recent_track(last RTK positions),sensor_stop, anddrive_recoverableonly with activityerror. - Map image. A 512 x 512 PNG rendered in Vision with Pillow from the generic map model, heading up, with mower, dock, areas, paths, no-go zones, the recent track, a scale bar and a north arrow; one extra image after the camera frames (it never counts against the frame limit).
-
Fallback. When the model recommends nothing (or something the rules reject) without a risk flag and the route check allows it, the proposal becomes
return_to_dockwithfallback: true, labelled as such in the app and the notification. Like every proposal it runs only after a human approves; before the command is sent the route check runs again on the newest state and map, and a lift in between aborts it. Risk flags still mean nothing is offered.
Mirrored from docs/adr/0033-vision-assisted-recovery-with-approval.md at commit 8f3f7119bf4d91d9f8099b4597d37bec6990bf2f. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 8f3f7119bf4d91d9f8099b4597d37bec6990bf2f. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- ADR 0045: Camera relay
- Development
- Operations
- Spikes
- Guides
- Original plan (German)