Repository navigation
ADR 0070 Virtual Zones
Accepted (2026-10-30)
Some mowers report a GPS position but share no map with MowDeck: Worx Landroid models with the GPS or RTK module, Husqvarna models with capabilities.position (both ADR 0066). Their owners see a dot on an empty grid. They want to know where in the garden the mower is ("Vorgarten", "hinter dem Haus"), how long it spends in each part, and to be told when it is somewhere unexpected. The vendor cannot be asked to mow a MowDeck polygon: these mowers follow their boundary wire or their own map; at most they offer numbered zones as starting points (Worx zone:<n>, ADR 0066 amendment: StartPlan of kind zone).
Forces:
- Provider neutral and capability driven (ADR 0066): no vendor names in the core, no dead UI.
- The map projection and the aerial photo exist already (ADR 0023, ADR 0054). Positions arrive on the telemetry stream every few seconds while mowing (heartbeats every 10 s), with very different accuracy: plain GNSS scatters by metres, an RTK fix by centimetres.
- Authorization follows the mower (ADR 0014): whoever sees the mower sees its data; whoever controls it changes it.
- The theft tracking mode (ADR 0058) showed that a few tables next to the position track belong in Telemetry rather than in a new service.
Telemetry owns the zones, their definitions as well as the derived "current zone" and the time per zone. The only consumer of a zone is the position stream, which Telemetry alone reads; it also holds what decides whether zones are offered (the map snapshot, the capabilities, the positions) and the aerial photo the zones are drawn on. Splitting definitions into Fleet would need a new event, a projection of every polygon in Telemetry and an eventually consistent window in which a freshly drawn zone is unknown to the evaluation, for no second consumer. Fleet keeps what commands need: a zone start goes through the existing StartMow.plan_ref, which Fleet already validates. Ports and adapters as in the rest of the service: application/zone_ports.py (ZoneStore, PresenceTx), adapters/db/zone_store.py (PostgreSQL), adapters/memory_zones.py (tests), pure geometry and rules in domain/zones.py, the use cases in application/zones.py, the routes in adapters/api/zone_routes.py.
Data model (migration 0013, additive): virtual_zones (id, account, mower, name up to 60 characters, ring as JSONB [[lat, lon], ...] open, area_m2, optional start_plan_ref, created and updated time, creator), zone_presence (one row per mower: current zone or none, since when, the last evaluated report and position, its fix, whether the mower mowed, until when time was counted) and zone_days (mower, zone, UTC day: seconds in the zone, of those mowing, entries; deleted with the zone by a foreign key). A mower removal deletes all three with the rest of the mower's rows.
Geometry. WGS84 rings; planar work happens in a local tangent plane around the ring's vertex mean (mowdeck_control.geo). A ring is accepted open or closed; corners closer than 5 cm are merged. Limits: 3 to 64 corners, no crossing or touching edges (an O(n²) segment test, at most 2016 pairs), 1 m² to 10 ha, at most 1 km wide, every corner within 1 km of the mower's map origin or last position (a zone belongs to the garden; this also keeps the aerial photo proxy from being stretched to arbitrary places), at most 32 zones per mower. Violations are 422 with a JSON pointer (#/ring, #/name, #/start_plan_ref). The web app checks the same limits while drawing (libs/mowdeck-ui/src/lib/map/virtual-zones.ts).
Evaluation. After ingestion has committed a state, Zones.observe evaluates it (a failure is logged and never fails the ingestion). Mowers without zones cost one indexed query. Prepared shapes (local ring and bounding box) are cached per zone revision; the point test is a bounding box check, ray casting and the distance to the border, so a mower with 32 zones of 64 corners costs a few thousand float operations per report. Evaluations of one mower are serialised by a transaction level advisory lock and the presence row (FOR UPDATE), so two replicas never count an interval twice; older reports are ignored.
Hysteresis. A mower on a border must not flap between zones because of GPS scatter. The band depends on the fix the report carries: 0.3 m with an RTK fix, 1 m with a float fix, 0.5 m for the dock pose, 3 m for plain GNSS or an unknown quality. The current zone holds the mower while the fix is inside it or less than the band outside it; another zone is entered only when the fix lies inside it by at least the band (or, when no zone holds the mower, inside at all). Of several candidates the smallest wins, so a bed drawn inside the lawn takes over once the mower is well inside it. To change, a fix must move a band beyond a border, and back a band beyond it the other way.
Time per zone. The interval between two reports (the later of heartbeat_at and observed_at) counts for the zone the mower was in, as mowing time when it mowed at the earlier report (raw.blade_on, else activity mowing), split at UTC midnight, unless the gap exceeds 10 minutes or the link was lost in between. Entering a zone counts one entry. Drawing, reshaping or deleting zones re-evaluates the last position at once without counting time, so the owner sees "the mower is in this zone" right after saving.
Availability is decided by data the server holds, not by the vendor. GET /mowers/{id}/zones answers availability: available when the mower's map snapshot has no areas and it reports positions (a current one, or one in the last 30 days); vendor_map when the vendor shares map areas; no_position otherwise. There is no capability flag for positions (a vendor module may or may not be fitted, e.g. Worx' GPS module), so observed positions are the honest signal. Drawing is refused with 409 zones-unavailable (code vendor_map, no_position, or limit) unless available; zones drawn earlier stay listed if that changes. The web app shows the zone entry points only when the server offers zones or zones exist: no dead buttons.
Vendors with maps (decided: not offered). A mower with vendor areas already has zones, and they are what it can actually be sent to; a second, independent zone system on top would contradict them (two names for one lawn, two "current zones"). The follow-up is to derive the current vendor area from the same evaluator instead.
Vendor zone starts as an optional, provider declared mapping. A zone may name one of the mower's start plans of kind zone (StartPlan, declared by the connection in MowerCapabilities.start_plans; ADR 0066). Telemetry accepts only a reference the stored capabilities declare (422 otherwise) and offers the list as zone_plans. The zone page then shows "Hier starten", which sends the existing start_mow {plan_ref} through Fleet, the controller and the connection, which validate it as before. No new capability is needed, no adapter changes, and nothing claims MowDeck can send a mower into a polygon; without declared zone plans there is no link field.
API (all under /api/v1/telemetry): GET /mowers/{id}/zones (zones, presence with zone id, name and since, zone_plans, the map anchor, the limits), POST /mowers/{id}/zones, PATCH /mowers/{id}/zones/{zone_id} (name, ring, start_plan_ref, unlink_start_plan), DELETE /mowers/{id}/zones/{zone_id} and GET /mowers/{id}/zones/stats?days=1..92 (seconds, mowing seconds, entries, per UTC day).
Authorization needs no model change: reading needs can_view on the mower, drawing, changing, deleting and linking need can_control (as renaming a map area); a foreign mower is 404, a visible one without the right 403. Every query also filters by the token's account.
Aerial photo. The tile proxy of ADR 0054 serves the box of the map plus margin, grown by the corners of the zones, so a zone at the edge of the garden still lies on the photo. The box can only grow within 1 km of the garden (the corner limit above).
Web. /mowers/{id}/zones: the mower map (the vendor's snapshot when it has a geographic reference, else an empty map anchored where the server says) with the zones as dashed "surveyor's tape" outlines and name tags, the current zone filled in the mowing colour, the mower and an aerial photo toggle when offered. "Zone zeichnen" switches to drawing: each tap on the map sets a corner, a bar on the map shows the count, the reason a drawing cannot be saved yet (edges cross, too small, ...), "Ecke zurück", "Abbrechen" and "Fertig"; a sheet asks for the name and, when the mower declares zone starts, the link. The side panel says where the mower is now and since when, and lists the zones with their area, a bar of the time in each over the last seven days, mowing time and entries, "Hier starten" for linked zones and "Bearbeiten" (rename, relink, redraw the shape, delete). The mower page shows a "Zonen" button in its top bar and a chip with the current zone next to the presence; a mower without a vendor map gets "Zone zeichnen" in its empty map. German and English texts; phone first (map on top, panel below; side by side from 900 px). MowerMap (libs/mowdeck-ui) draws zones, the current and the selected zone and a draft; the pure layer model is unit tested.
Events: none yet. Nothing consumes zone changes today; the "left all zones" hint and zone history in notifications need a contract (event.zones.presence) and a consumer in Notifications, listed as follow-up rather than published without a reader.
- Owners of GPS mowers without a vendor map get a meaningful map: named zones, where the mower is, and how long it spends where. MowDeck never pretends to command a mower into a polygon; the one way to start "there" is the vendor's own zone start, declared by its connection.
- One more query per stored state (zones of the mower), and for mowers with zones a short transaction per report. The cost is bounded by the zone and corner limits.
- The current zone is as good as the positions: with plain GNSS the 3 m band delays changes by a few metres; a zone smaller than the scatter will be entered rarely. Time counts only while the mower reports; long silences (over 10 minutes) and link losses count nothing.
- Time per zone is kept per UTC day without a retention limit (one small row per zone and day), like the health day totals; it is deleted with the zone or the mower.
- Follow-up (not in this change): the
event.zones.presencecontract and Notifications kinds ("left all zones", unexpected zone), coverage per zone (the coverage heatmap clipped to a zone), the current vendor area for mowers with vendor maps, zone history on the health page, zones in the fleet map, and editing single corners by dragging (today a shape is redrawn).
Mirrored from docs/adr/0070-virtual-zones.md at commit 0031f1857a87c89bad7356661ca60578f18fedaf. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 0031f1857a87c89bad7356661ca60578f18fedaf. 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
- ADR 0046: Device settings as capabilities
- ADR 0047: The RTK base station as a Fleet device
- ADR 0048: Maintenance commands
- ADR 0049: Manual driving over the Mammotion cloud
- ADR 0050: Passkeys and the app login hand-off
- ADR 0051: Sign in with GitHub
- ADR 0052: Sign in with Apple
- ADR 0053: Retention and downsampling of the position track
- ADR 0054: Aerial photo under the mower map
- ADR 0055: Deploy maintenance window
- ADR 0056: Camera photo in push notifications for errors and theft alarms
- ADR 0057: Step-up re-authentication for critical actions
- ADR 0058: Theft tracking mode
- ADR 0059: iOS widgets and the mowing Live Activity
- ADR 0060: Apple Watch app
- ADR 0061: Mowing angle rotation
- ADR 0062: Setup tour for new users
- ADR 0063: Matter server (mowers in Apple Home and Google Home)
- ADR 0064: Personal access tokens and the public API documentation
- ADR 0065: Security center and new-login alerts
- ADR 0066: Cloud provider plugins and adapters for more vendors
- ADR 0067: Error code lexicon, provider independent
- ADR 0068: Public demo mode
- ADR 0069: Provider conformance kit and provider package template
- ADR 0070: Virtual zones for mowers without a vendor map
- ADR 0071: Capability matrix generated from the adapters
- ADR 0072: API explorer in the web app, for the public demo
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- Adding a provider: template and conformance kit
- Adding an error lexicon for a vendor
- Design: identity and authorization
- Design: fleet, telemetry, realtime
- Design: web app
- Web app
- Home Assistant integration
- Camera source bridge protocol
- Fuzzing and property-based tests
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Building the Bluetooth bridge (ESPHome on a XIAO ESP32-S3)
- Releases
- Vendor library updates
- Billing operations
- Telemetry: retention and position downsampling
- iPhone and Android apps
- Passkeys
- Sign in with GitHub
- Aerial photo under the map
- Sign in with Apple
- Theft tracking mode
- Matter server
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Spikes
-
Guides
- Guides
- ESP32 bridge: purchase and setup
- Signing in with a passkey
- Signing in with GitHub
- Error codes in plain words
- Aerial photo under the map
- Signing in with Apple
- Confirming critical actions
- Your security page
- iPhone widgets and the mowing Live Activity
- Apple Watch
- The setup tour
- Apple Home and Google Home (Matter)
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)