ADR 0035 Server Side Camera Frames And Automatic Analysis - lukislp/mowdeck GitHub Wiki
Accepted (2026-10-11). Amends ADR 0033 (images of a real mower, the trigger of an analysis) and ADR 0032 (a second Agora viewer besides the browser).
AI assist (ADR 0033) sees the mower's surroundings in two ways: the web app sends a frame of its open camera panel, or Vision asks the Device-Gateway (gateway.mower.<id>.photo), which calls the adapter's optional PhotoCapture.capture_photo(). Only the simulator implements it. On the owner's LUBA 2 AWD X the camera is reachable only as a live Agora channel the browser joins (ADR 0032), so without an open browser Vision has no picture. The owner now wants the analysis to start by itself when a mower gets stuck (the plan's "Automatic AI analysis on mower errors", so far "not for now"), and that needs a picture without a browser.
Research of PyMammotion 0.10.9 (the pinned version) for a native still image:
- No protobuf message takes or returns a picture.
luba_mul.protohasMulSetVideo {position, vi_switch}(switch a camera's stream on or off) andMulSetVideoAck(an error code);mctrl_nav.protohasvision_ctrl_msg {type, cmd}without any documented snapshot command;mctrl_sys.protocarries vision point clouds and brightness statistics, no image. - The HTTP API (
pymammotion/http/http.py) hasget_stream_subscription(the Agora token, which also makes the mower publish) andget_video_resource(the 4G video budget), nothing that returns an image; no other module mentions photo, snapshot, picture, capture or screenshot. - The vendor app's "take photo" therefore works on the decoded live stream on the phone.
A capture worker joins the camera's Agora channel in a headless browser and returns one frame; the Device-Gateway uses it for adapters that offer a live camera but no photo. Vision starts an analysis by itself when a mower enters a recoverable error and a user of the account opted in; the approval stays with a human.
-
Where. A separate process in the Vision image,
python -m vision.capture(packagevision.capture), built with--build-arg WITH_CAPTURE=true: the extracapture(Playwright 1.63.0), Playwright's Chromium headless shell with its system libraries, and the Agora Web SDKagora-rtc-sdk-ng4.24.8 (the version the web app pins, downloaded from npm and checked against the lock file's sha512) at/opt/agora. The default Vision image stays without a browser; Compose starts the worker only with the profilecapture, Helm withservices.visionCapture.enabled. -
How.
POST /api/v1/capturewith aCameraSnapshotRequest(contracts: theCameraSessionincluding its secret token, the camera's Agora uid,timeout_seconds,max_bytes,max_width) answersimage/jpeg. A new browser context per request serves a page from the reserved originhttps://capture.mowdeck.invalidthrough request interception (a secure context, the SDK from the local file), joins with the session's token and uid (rtcprofile, codecvp8, the session's encryption like the web app), waits for the requested publisher, subscribes, waits for the first decoded frame plus 400 ms for a complete key frame, draws it into a canvas scaled tomax_widthand encodes JPEG (quality 0.8, lowered twice if the frame is abovemax_bytes), then leaves the channel. -
Limits. At most
MOWDECK_CAPTURE_MAX_CONCURRENCY(2) captures at once, a request waitsCAPTURE_QUEUE_SECONDS(10) for a slot and then gets 503; the frame wait is capped at 30 s (504), a camera that does not publish or a browser error is 502 with a short reason (no-publisher,no-frame,agora:<code>). A shared token (MOWDECK_CAPTURE_TOKEN) protects the internal endpoint; the session token and the image are never logged. -
Gateway.
MowerSession.capture_photo()uses the adapter's ownPhotoCapturewhen there is one (the simulator), else, whenMOWDECK_CAPTURE_URLis set and the connection isCameraCapable(the Mammotion cloud connection), it requests a fresh camera session from the adapter and asks the worker for a frame of the first camera with a publisher uid (front left, uid 1), one capture per mower at a time (HttpCameraSnapshotter, portCameraSnapshotter). Timeouts: worker frame waitMOWDECK_CAPTURE_TIMEOUT_SECONDS20 s, the whole photoMOWDECK_PHOTO_TIMEOUT_SECONDS30 s, Vision's photo requestMOWDECK_VISION_PHOTO_TIMEOUT_SECONDS35 s. The worker is called from the gateway, not from the GPL adapter, so every camera-capable adapter benefits andmowdeck-control-mammotionneeds no client of it.
-
Opt-in. Per user, default off:
user_preferences.auto_analysis(with the principal kind the analysis acts as),GET/PUT /api/v1/vision/me/preferences; enabling needs the plan featureai_recover. The web app shows the switch "Automatische KI-Analyse bei Fehlern" on the AI provider page with the installation's limits.MOWDECK_VISION_AUTO_ENABLED=falseswitches the feature off for the installation. -
Which errors. Activity
error, not lifted (raw.lifted), and the error code (trimmed, lower case, a leading minus dropped) inMOWDECK_VISION_AUTO_ERROR_CODES, or a state markeddrive_recoverable. Default set from PyMammotion's error table and the simulator: stuck or trapped (stuck, 1201 robot stuck, 1421 stuck during recharge, 11112 trapped, 2000701 front wheels may be stuck), blocked by an obstacle (1206, 11113 channel blocked, 11121 obstacle ahead, 11122 channel to the area blocked), outside the work area (1100, 1145, 1411, 1422) and wheel slip (1032, 1218, 11132, 11134). Never: lift and tilt stops (1004, 1030, 2502, 2801, 11131, 11133), low battery (1005 to 1007), blade jams (1202), motor faults. -
Trigger. A durable consumer
vision-auto(queue group) ontelemetry.mower.*hands each state toAutoAnalysis.on_state. A shared watch (auto_watch, one row per mower, swapped in a row-locked transaction) remembers the current recoverable code, so only entering an error (or changing to another recoverable code) counts. Then, in a background task: states older thanMOWDECK_VISION_AUTO_MAX_STATE_AGE_SECONDS(120) are ignored; no analysis when any proposal of the mower was created withinMOWDECK_VISION_AUTO_COOLDOWN_MINUTES(10) or one is open; at mostMOWDECK_VISION_AUTO_DAILY_PER_MOWER(20) automatic analyses per mower and UTC day; the account's plan needsai_recover. The users who opted in are asked in the order they opted in: the first who holdscan_driveon the mower (OpenFGA), has a usable provider (own, or the installation's withai_recover_hosted) and daily quota left (MOWDECK_VISION_USER_DAILY_REQUESTS) becomes the requester. Without a photo from the gateway nothing is analysed. -
Proposal. The analysis is a normal round 1 (the dark-frame light handling included) recorded with
trigger = errorandtrigger_error_code;VisionProposalCreatedcarriestriggeranderror_code; Notifications sends it as the new kindvision_auto_proposal("KI-Vorschlag für <Mäher>", deep link to the proposal,can_driverecipients; preferences that hadvision_proposalget the new kind by data migration). Nothing moves until a user approves; the proposal page says "Automatisch ausgelöst durch Fehler".
Typical, after the state arrives: watch and limits a few ms; stream token about 1 s; the mower starts publishing and the worker gets the first frame in about 3 to 10 s (unverified on hardware, at most 30 s); a dark frame adds the light (up to 10 s) and a second frame; the model 5 to 30 s; the push about 1 s. So a proposal usually reaches the phone 15 to 45 s after the mower got stuck.
-
A native snapshot command. None exists in PyMammotion 0.10.9 (see Context); reverse engineering an undocumented
vision_ctrl_msgcommand would risk the mower's state and still not return pixels over MQTT. -
Decode the stream in Python (
agora-python-server-sdkor a native Agora SDK). The server SDK is a large closed binary per architecture (no arm64 wheel for every target), with its own licence; its token and encryption handling would differ from the web app's, which already works on the owner's mower. - Ask the open browser. Works only while someone looks at the camera, which is exactly what the automatic analysis must not need.
- Headless browser in the gateway image. Would put Chromium next to every mower connection and couple browser crashes to the safety-critical gateway; the separate worker keeps the gateway small and can be scaled or left out.
- The capture image is about 0.5 GB larger than the Vision image (Chromium headless shell and its libraries about 300 MB, Playwright's driver about 40 MB, fonts and codecs); the default images do not change. Each capture briefly runs a browser page with a video decoder (about 150 to 250 MB of memory, a second of CPU).
- Every capture asks the Mammotion cloud for a stream token and makes the mower publish video, which uses the 4G video budget (
quotaof the camera session) and Agora minutes, like opening the camera panel for a few seconds. - The worker joins with the viewer uid of the token it gets. If the cloud hands out the same uid as to an open browser, one of the two viewers is dropped (Agora UID conflict); the stream request asks only for the front left camera (
cameraStates1,0,0), which may switch off the second camera of a stereo view that is open at the same moment. Both are unverified on hardware. - Chromium's open-source build may not decode every codec; when the mower's stream needs H.264 and the headless shell cannot decode it,
CAPTURE_BROWSER_CHANNEL=chromewith an image that contains Google Chrome is the fallback (not built by default). - Automatic analyses cost money per error (bounded by the cooldown, the per-mower daily limit and the per-user cap) and send images to the user's provider like manual ones.
- Execution of a proposal on a real Mammotion mower still needs manual driving (only over the BLE bridge) and
drive_recoverablefrom the adapter, which it does not set yet; until then automatic proposals on the cloud connection can be reviewed but an approval aborts at the controller.
Mirrored from docs/adr/0035-server-side-camera-frames-and-automatic-analysis.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.