Repository navigation
ADR 0035 Server Side Camera Frames And Automatic Analysis
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). Amended by ADR 0045 (accepted 2026-10-18): Vision takes its frames from the camera relay's frame tap (a vision lease with its limits; the Device-Gateway's RelaySnapshotter). The capture worker below is deprecated: it stays one release as the gateway's fallback for mowers the relay cannot serve (relay not configured or not reachable, no source driver for the camera) and is then removed.
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, 1214 channel obstructed, 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 bc84cc2590cb3e4026c28d16755a7ee056e54ece. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit bc84cc2590cb3e4026c28d16755a7ee056e54ece. 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)