Repository navigation
ADR 0030 Mowing Patterns And Planner
Accepted (2026-10-10); amended by ADR 0043 (2026-10-15): on request the lane ends of stripe patterns are shaped as arcs, bulb turns or three-point turns (reverse segments), clipped to the area and the spot run's geofence.
Phase 5 ("Spot & Kamera") brings spot mowing of any shape and re-mowing missed patches (project plan, "Mähmuster & Routen", "Spot-Mähen beliebiger Formen", "Verpasste Flecken nachmähen"). Patterns are plug-ins: input a polygon with holes, mower data and settings that describe themselves as JSON Schema; output a neutral mowing plan of segments (drive with the blades on or off at a speed, in order) that does not depend on the mower model. The same validation chain applies to every pattern (inside the area with a safety margin, the turning radius is feasible, no no-go zones, blades off on transfers), so a plug-in cannot produce an unsafe route. Patterns can be chained ("2 border laps + diagonal + re-mow") and saved as templates. The Planner computes, checks and previews (CPU-bound, "no own data", reusable as a library). Execution goes through the Device-Gateway: as the mower's own path sequence, or as a native job when the mower runs the pattern itself. The LUBA runs stripes at any angle, checkerboard, border laps and the order of both natively, but no arbitrary paths are known (spike 0001, section 3.2); MowerCapabilities.custom_paths is False for it, and mowdeck_control.translation.plan_to_native_job is the seam to native jobs (ADR 0012).
mowdeck-patterns is a pure library (Apache-2.0) on Shapely 2.1. No network, no database; it depends on mowdeck-contracts and shapely>=2.1 only. All geometry is local metres of the mower's map (MowerMap, ADR 0023). The models: Area (boundary, holes, no-go zones, obstacles; at most 5000 vertices, 50 000 m² and 1 km across), MowerSpec (cutting width, overlap, turning radius, mowing and transfer speed, safety margin, time per turn), MowPlan (ordered Segment{polyline, blades_on, speed, kind} with kind mow, turn or transfer, estimated_duration_s, covered_area_m2, area_m2, pattern_chain, warnings). The spacing of runs is the cutting width minus the overlap; while cutting, the mower's centre keeps half the cutting width (at least the safety margin) from every edge, hole and zone, so the blade reaches the edge without the mower leaving the margin.
A pattern is a class with key, name, settings_schema() and compute(area, mower, settings) (the MowPattern protocol), registered as an entry point in the group mowdeck.patterns (name = key). The built-ins derive from BasePattern: settings are a Pydantic model (its JSON Schema is the form the app builds, the same model validates and refuses unknown keys), the pattern only orders runs, and the shared code joins runs with turns (short, inside the cutting region, blades on) or routed transfers (blades off, shortest way inside the safe region along a visibility graph of its corners), then estimates duration (driving time plus a fixed time per turn above 60 degrees) and coverage (blades-on segments widened to the cutting width, intersected with the mowable region). Built-in: stripes, diagonal, checkerboard, spiral (concentric around the outer shape, cut at holes), border_fill (laps, then any built-in fill), contour (laps parallel to every edge including holes), auto_aligned (the angle with the fewest runs among 5 degree steps and the longest edges), cells (slab decomposition through concave corners and hole corners, each cell at its own best angle), alternating (angle from a rotation list by run_index, or a random angle deterministic for seed and run_index), edge_only and remow (stripes over gap polygons, or over the area minus a covered polygon). Waves, circles, rays and lawn art are later. chain() runs patterns one after the other with blades-off transfers between them (at most eight).
One validation chain for every plan. validate(plan, area, mower) raises PlanRejected(reasons): every segment inside the area minus the safety margin (holes and zones excluded); no blades-on segment through a no-go zone, an obstacle or a hole; transfers with the blades off; speeds within the mower's limits; and a turning heuristic: a heading change of theta needs r * tan(theta / 2) of straight track on both sides of the corner, a reversal is impossible for r > 0 (mowers that turn on the spot use r = 0). plan() is compute plus validate and is what the Planner calls; a plug-in's output never reaches a mower unchecked. coverage(track, width) and gaps(area, covered, min_area) turn a position track into covered ground and the gaps left (slivers below 5 cm are opened away), preview(plan) gives a GeoJSON-like collection.
Plans travel inline in the start command, not by reference. The contract SpotMowPlan{plan_hash, pattern_chain, segments, estimated_duration_s, covered_area_m2, native} carries SpotSegment{kind, blades_on, speed_mps, path} where path is an encoded polyline (the map-services algorithm, centimetre precision): a plan of a few thousand points stays well below the bus message size. plan_hash is the SHA-256 of the canonical JSON of chain and segments and must match, so a recorded hash identifies exactly one route. StartMow gains the optional spot_plan as a third target (exactly one of area_ids, plan_ref, spot_plan; old messages stay valid). The alternative, a plan store with plan_ref, would give the Planner data of its own and every consumer a dependency on it; inline plans need nothing but the contract.
Execution decides by capabilities. MowerController accepts a spot_plan when capabilities.custom_paths is true; otherwise only when the plan carries native (SpotNativeJob{area_ids, pattern}: the chain is one native pattern, or border_fill with a native fill and at most four laps, on a whole map area that the mower supports), and then sends the native StartMow(area_ids, pattern) instead, checked like any area start. Anything else is rejected ("custom paths not supported ..."). The simulator now reports custom_paths=True (option custom_paths=False simulates a LUBA-like mower) and follows the segments with their blades and speed; the Mammotion adapter keeps custom_paths=False and rejects a spot plan without a native job itself as well. The Device-Gateway passes the command through unchanged.
Fleet records the hash, gates the feature, limits the size. A start_mow with a spot_plan needs the plan feature spot_mowing (declared by the Planner, granted to home and fleet, unlimited has *) besides control, may be at most MOWDECK_SPOT_PLAN_MAX_BYTES (256 KiB) as JSON, is refused for bulk commands (a plan is computed for one mower's map), and its record keeps plan_hash (new column, CommandView.plan_hash) and a summary instead of the segments, so the command audit stays small.
The Planner service (services/planner, AGPL) is a thin HTTP layer: GET /patterns (registry with schemas and the native hint), POST /plans/preview (mower or raw map, a map area and/or a spot polygon in local metres or WGS84, a chain or a saved template, optional mower data overrides; answers totals, warnings, the preview and how the mower can run it: custom_paths, native, unsupported or unknown), POST /plans (the same plus the SpotMowPlan and the ready StartMow for Fleet; nothing is stored, the same request gives the same hash), POST /coverage/gaps and templates. It keeps a directory of map and capability snapshots from the DEVICE stream (durable consumer, newest message per subject first, like the Scheduler and Telemetry), converts WGS84 with the map's geo reference, and refuses drawn spots outside the mapped areas (the mower knows nothing beyond them). Geometry runs in worker threads, at most MOWDECK_PLANNER_MAX_PARALLEL per replica. Previews need can_view on the mower, plans can_control (Fleet checks again), every planning endpoint the plan feature spot_mowing; planner.view is an account key implied by mower.view.
Coverage over the bus. Services do not call each other over HTTP, and subscribing to every position would be heavy, so Telemetry answers a core NATS request PositionTrackRequest{account_id, mower_id, start, end, limit} with PositionTrackReply{count, track, first_at, last_at, truncated, error} (the track an encoded polyline of latitude and longitude, seven decimals, up to 50 000 points). The subject is tracks.positions.query, deliberately not telemetry.positions.query: the TELEMETRY stream captures telemetry.>, and a request on a stream subject is answered by JetStream's publish acknowledgement instead of the responder. The track has no blade state; everything driven in the chosen time range counts as covered, jumps over 3 m break the track.
Deviation from "no own data": templates. Templates are per account and must survive restarts, so the Planner owns a small PostgreSQL database (templates, plus the snapshot directory mower_facts) instead of storing JSON in Fleet, which would couple a planning concept to the mower registry. Plans themselves are still never stored. Templates hold only pattern keys and settings; every principal of the account reads them, saving and deleting needs can_control on the account.
- A new pattern is a class plus an entry point; the Planner, the app (form from the schema) and the validation chain need no change. Gating individual patterns by plan stays possible (one feature key per pattern) but is not done yet;
spot_mowinggates them all. - Mowers without
custom_paths(the LUBA today) run only whole-area native plans; spot polygons, spirals, contours, cells and re-mowing need a mower or adapter that follows paths. The LUBA's path following remains an open question of the spike. - Coverage counts transfers as mowed (no blade state in the position track); choose the job's time range. A blade flag in the track would make it exact.
- Plans are computed for the map at request time; a map changed in the vendor app between preview and start is not detected by the hash. The simulator and the controller check segments only for blades-off transfers, the Planner's validation chain is the safety check.
- Coordinates of spot plans are rounded to centimetres. Patterns keep an extra centimetre from the margins, and the Planner runs the validation chain on the plan before and after rounding.
Decision. The settings of a native start that every client offers are one contract, MowPattern{name, angle_deg, border_laps, spacing_m} (the common, validated part of StartMow.pattern; adapters may accept further keys), and Fleet keeps them per mower as the mowing defaults: PUT /api/v1/fleet/mowers/{id}/mow-defaults with {"pattern": MowPattern | null} (can_control, 404 semantics; unknown keys and values out of range are 422; null drops the defaults), MowerOut.mow_defaults ({"pattern": {...}} or null) on every mower answer, the audit action mower.mow_defaults_changed only when the value changes, and TargetMower.mow_pattern in the reply of fleet.targets.resolve, so the Scheduler starts from the same defaults as the web start sheet and Home Assistant. Without saved defaults every client uses stripes at 0 degrees with one border lap. Nothing is sent to the mower: a start still names its own pattern, and whether the mower runs it is decided with MowerCapabilities.native_patterns. Inside Fleet the defaults are the domain value MowDefaults{pattern: MowPattern}; the stored shape (mowers.mow_defaults, nullable JSONB {"pattern": {...}} without unset optional keys, migration 0009) belongs to the database adapter alone, and a row this version cannot read counts as "no defaults".
Alternatives: keeping the settings in the browser would lose them across devices and would leave Home Assistant and the Scheduler without them. Planner templates are account-wide pattern chains for spot plans, not the native start settings of one mower, and the Scheduler would then depend on the Planner to issue a start. A setpoint sent to the mower does not exist: the LUBA takes the pattern per job, not as a persistent setting. One pattern per mower rather than per area keeps the start sheet and the schedule form small; per-area defaults can be added as a further key later.
Consequences: a pattern the mower does not support can be saved (the defaults describe what the user wants; the start sheet offers only what native_patterns reports and the start is checked by the controller). spacing_m stays null unless the user sets it, so the model's own spacing remains the default. The schedule form still edits only the angle and the rotation list; the other settings come from the defaults.
Mirrored from docs/adr/0030-mowing-patterns-and-planner.md at commit b862a17f1b54a901f286720fb18d2999580f6d15. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit b862a17f1b54a901f286720fb18d2999580f6d15. 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)