Repository navigation
ADR 0069 Provider Conformance Kit And Package Template
Proposed (2026-10-30). Builds on ADR 0012 (connection interface and safety rules), ADR 0046 (settings as capabilities), ADR 0066 (cloud provider plugins, "no dead buttons") and ADR 0067 (error lexicons); changes none of their contracts.
MowDeck is a set of building blocks: a mower vendor plugs in as its own package (libs/mowdeck-control-<vendor>) through entry points, and MowDeck shows exactly what that vendor's mowers can do, gated by MowerCapabilities, capability-declared settings and provider-declared options (ADR 0066). Three adapters exist (Mammotion, Husqvarna, Worx). Each tests itself with its own fake vendor client, and each re-tests the shared contracts in its own words: the Husqvarna package checks that its spec imports without the SDK and that its descriptor has German texts, the Worx package checks its lexicon against its state mapping, libs/mowdeck-control/tests/test_control_minimal_adapter.py checks a hand-written minimal adapter. Nothing checks every adapter against the same list, so a contract one adapter forgets goes unnoticed: the Husqvarna adapter, for example, declares neither map areas nor start plans, so no client can offer it a start, although the ADR 0066 amendment names it as the case start plans were made for.
Starting a new vendor means copying an existing adapter and deleting what does not fit, which drags vendor details along and makes the licence an accident of the copied package (the Mammotion and Worx packages are GPL-3.0 because their SDKs are, ADR 0005, ADR 0011).
Forces: the core must stay vendor neutral (no core code imports an adapter or names a vendor, tools/repo-checks/tests/test_vendor_isolation.py); a vendor with few features must plug in with little effort; checks may only enforce contracts that exist; no new heavy dependency.
A new Apache-2.0 library mowdeck-provider-testkit (import mowdeck_provider_testkit) holds one vendor-neutral pytest suite. A provider package subclasses it in its tests and names itself and its fake vendor client:
class TestAcmeConformance(ProviderConformance):
subject = ProviderUnderTest(
distribution="mowdeck-control-acme",
fake_credentials={"email": "garden@fake.mowdeck.test", "password": "fake-cloud-password"},
emitted_error_codes=lambda: (str(code) for code in SDK_ERROR_TABLE),
)The kit derives everything else from the installed distribution: its entry points (loaded through mowdeck_control's own loaders, the ones the Device-Gateway uses, with a source that yields only this distribution's entry points) and its pyproject.toml ([tool.mowdeck.adapter]). For a cloud provider it builds the connection the way the gateway does: ProviderSpec.create(ProviderContext(fake=True)), discover with the fake credentials, create_connection for the first online mower with the device name, IoT id, provider id and credentials in the options, wrapped in MowerController. A package without a cloud provider (or with a special fake) passes a connection_factory instead. Every command goes through the controller, so the checks see what a user sees. The checks are plain functions in mowdeck_provider_testkit.checks that raise AssertionError; the pytest class only calls them, skips what does not apply (with the reason) and turns known_violations (test name to reason) into strict xfails, so a fixed violation fails until it is taken off the list.
Why a new library and not a module of an existing one: the kit depends on pytest and pytest-asyncio, which must not become runtime dependencies of mowdeck-control (installed in the gateway image) or mowdeck-contracts; a test-only package keeps them out of every image. Its name has no mowdeck-control- prefix, so the vendor isolation test treats it as core: it may not import a vendor package, name a vendor or load code dynamically, and it does none of that.
| Check | Source |
|---|---|
Package named mowdeck-control-<vendor>, import package installed |
ADR 0066 ("How to add a vendor", decision 6) |
Registers mowdeck.connections or mowdeck.cloud_providers, ships mowdeck.error_lexicons, every entry point points into its own package |
ADR 0066 decision 6, ADR 0067 |
Every entry point loads and names what it registers consistently (a known ConnectionKind, ProviderSpec.provider equal to the entry point name, lexicon vendor equal to the entry point name) |
ADR 0012, 0066, 0067 and the loaders in mowdeck_control
|
vendor-modules lists the SDK, brand-names is a list |
ADR 0066 decision 6 |
| No import of another adapter package | ADR 0066 decision 6 |
| The provider spec imports with the vendor modules blocked |
mowdeck_control.providers ("should be importable without the vendor SDK") |
Valid provider id, descriptor names the same provider, on by default only when tested on hardware, descriptor survives JSON, every text in en and de, the fake credentials fit the fields |
ADR 0066 decisions 1 and 2, the localized-texts amendment |
The fake account lists a mower; a changed secret raises CloudLoginRefused whose message does not repeat it |
ADR 0066 decision 1, CloudLoginRefused
|
is_connected only between connect and disconnect, both idempotent; watch_state yields the current state first and ends on disconnect
|
ADR 0012, MowerConnection
|
The connection's kind is in connection_kinds; every declared optional feature implements its protocol (settings, maintenance, undock, firmware, base station firmware, drive speed setting, camera, bridge scan) |
mowdeck_control.connection ("declares X and implements Y") |
States belong to the mower and fill no gateway-owned field (heartbeat_at, cloud_provider, connected); drive_recoverable only during an error and never for lift, tilt or collision; error_category only during an error and equal to the lexicon's category; only declared settings with valid values |
ADR 0012, ADR 0046, ADR 0067, field descriptions of MowerState
|
| Map areas or start plans exist, so a client can offer a start | ADR 0066 amendment (start plans) |
| Start (as a client sends it), pause, resume, emergency stop while mowing, return to dock, return ending the job: each executed or accepted | ADR 0012 interface, ADR 0066 ("a minimal adapter ... starts, pauses and docks") |
| Everything the capabilities do not offer is refused with a reason before the adapter is asked (manual drive, drive calibration, blade height outside the range, speed, light, keeping the job, firmware, base station firmware, drive speed, each undeclared maintenance action, an undeclared setting, an unknown area, an undeclared pattern) | ADR 0066 principle, ADR 0012 |
Everything they offer is taken: blade height at both ends of the range, speed, light on and off, every start plan, ReturnToDock(end_job=False) with dock_keeps_job, a native pattern on a map area; an unknown start plan is refused |
ADR 0066 principle and amendment, ADR 0012 |
Every declared setting takes a valid value; executed means the state shows it |
ADR 0046 |
Without manual_drive a drive session is refused with CommandRejected; with it, a session opens, takes a zero frame, and stop and close are idempotent |
ADR 0012, DriveSession
|
| The emergency stop is taken at rest, returns within 5 s while a start is still in flight, and still reaches the adapter on a mower the vendor reports offline | ADR 0012, MowerConnection rules, docs/ops/vendor-lib-updates.md
|
| The lexicon survives JSON, has English titles, each code finds its own entry, it is found by the package's provider ids and connection kinds, and it explains every code the adapter can report | ADR 0067 |
Assumptions where a contract leaves room, written into the check docstrings:
- Start, pause, resume, return to dock and the emergency stop have no capability flag; every adapter takes them (ADR 0066's minimal adapter). The fake mower is online and at rest when a check begins.
- "Taken" means
executedoraccepted; the fake answers with the vendor's behaviour, so a vendor that cannot pause would have to say so in a capability first (none exists today). - An emergency stop with nothing to stop is not a refusal. On a mower the vendor reports offline the stop must reach the adapter; the vendor may then answer that the mower is offline, so a refusal with a reason passes (the hardware checklist asks that the stop "is still sent", not that it succeeds).
- The fake cloud refuses a changed secret like the vendor would.
- A connection that declares start plans validates the reference, so an unknown one is refused (the amendment says the connection validates; the controller does not).
- The SDK-free spec import is a "should" in
mowdeck_control.providers; the kit enforces it, and a package that cannot meet it yet lists it as a known violation. - Lexicon entries need English only; German is MowDeck's second language and not required (ADR 0067), unlike provider texts (ADR 0066 amendment).
The kit runs in the three adapters' tests (test_mammotion_conformance.py, test_husqvarna_conformance.py, test_worx_conformance.py), each on its existing fake cloud, and in its own tests against the simulator, a conforming minimal adapter and one deliberately broken adapter per contract it guards. It found:
-
Husqvarna offered no start. Its map has no areas and it declared no start plans, so no client could start a Husqvarna mower. Fixed in the adapter: it declares
duration(kindnow), onework_area:<id>per work area of models with work areas (kindzone, the vendor's name) andschedule(kindschedule), the referencesaction_for_planalready accepted. -
The Mammotion spec imports PyMammotion. The package
__init__imports the connections, which import the SDK, so loading the spec loads PyMammotion. Harmless while the provider is on by default (the gateway creates it anyway); a lazy package__init__would fix it. Listed as a known violation (strict xfail).
A Copier template next to the service template renders libs/mowdeck-control-<vendor>: pyproject.toml with both entry points and [tool.mowdeck.adapter], a cloud provider plugin whose spec imports no SDK, a descriptor with English and German texts, a minimal connection (state; start through the start plan now, pause, resume, return to the dock, the emergency stop as an immediate pause sent even when the vendor reports the mower offline; nothing else declared), a small port to the vendor cloud with a fake client (one online and one offline mower) and a stub for the real client, an empty error lexicon, a README and the conformance kit wired into its tests, which pass as generated. uv run poe new-provider <vendor> runs it.
The licence is a conscious choice: the question sdk_licence (permissive or GPL, read from the SDK's sdist) has no default, rendering without it fails, a GPL SDK makes the package GPL-3.0-only with the GPL text, a permissive one keeps it Apache-2.0 (ADR 0005, ADR 0011). A Copier task copies the licence text from LICENSES/ (hence --trust, as for the service template), so the repository keeps one copy of each text.
tools/repo-checks/tests/test_provider_template.py renders the template (Apache and GPL), makes the result importable without installing it (its src on the path and a .dist-info with the entry points of its pyproject.toml, which is all importlib.metadata needs), runs its tests in a fresh interpreter and runs ruff and mypy (strict) on it, so the template cannot rot.
Copier because the repository already uses it (templates/service, copier in the dev group): no new dependency, the same answers file and update path. Alternatives considered:
-
A module of
mowdeck-control(mowdeck_control.testinggrows the suite): rejected, it would make pytest a runtime dependency of the gateway image or need an optional extra that every adapter test must remember. -
Checks in
tools/repo-checksthat iterate over all adapters: rejected as the only place, because a package outside the monorepo could not run them and the fake clients belong to each package; the repository checks keep what only the repository can see (the core never imports a vendor). - Cookiecutter or a hand-written render script: cookiecutter would be a second template engine for the same job; a script would re-implement Copier's questions, validation and answers file.
-
Copying the licence texts into the template: two more copies of texts that already live in
LICENSES/. -
Running the rendered package's tests in a fresh virtual environment: closer to an install but slow and needs the package index; the
.dist-inforoute exercises the same entry point discovery.
- A new vendor starts from a package that already passes every check, with the licence decided at the first question; a vendor with few features stays a small package that shows only what it supports.
- Every adapter proves the same contracts on its own fake client; a capability declared without its implementation (or a command offered and refused) fails in the adapter's own CI job, and so does a vendor library update that breaks one (the Dependabot PRs run
libs/). - The kit itself is tested against broken adapters, so a check that stops catching its violation fails there.
- Contracts the kit cannot see stay with each package's own tests: the vendor's state mapping, rate limits, login guards, the exact vendor commands.
- New contracts should get a check in the kit when they are made; the checks cite their source, so a changed ADR points at the check to change.
- Follow-ups: a lazy package
__init__in the Mammotion adapter (then drop its known violation); the Husqvarna start plans want a hardware check (step 4 of its checklist already covers starts by plan).
Mirrored from docs/adr/0069-provider-conformance-kit-and-package-template.md at commit 40b3352a0fbbbfbdd62a4a90daf85af499ec3385. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 40b3352a0fbbbfbdd62a4a90daf85af499ec3385. 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
- ADR 0073: Guided tour with coach marks
- ADR 0074: NATS authentication and per-service subject permissions
- ADR 0075: Chat control as an optional assistant add-on
- ADR 0076: Demo sandboxes, one account per visitor
- ADR 0077: Liveness, readiness and routing
- ADR 0078: Batched state ingestion
- ADR 0079: Fast demo sandbox claim and release
-
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
- Security assurance case
- Fuzzing and property-based tests
- CI and pull request scope
- Reproducible builds
- GitHub wiki mirror
-
Operations
- Operating the Compose stack
- Operating MowDeck on Kubernetes (Helm)
- Internal TLS
- 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
- Chat control (assistant add-on)
- Source code offer (AGPL-3.0 section 13)
- Public demo
- Load testing
- 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)
- Chat control
- Worx Landroid, Kress and LandXcape mowers
- Scripting MowDeck
- Capability matrix
- API reference
- Original plan (German)