ADR 0033 Vision Assisted Recovery With Approval - lukislp/mowdeck GitHub Wiki
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.
Mirrored from docs/adr/0033-vision-assisted-recovery-with-approval.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.