Repository navigation
Dev Error Lexicon
A mower reports faults as vendor codes (MowerState.error_code: Mammotion 1201, the simulator stuck). MowDeck shows them in plain words, with a severity, the likely cause and the steps that fix them, in the web app, in the push texts and through the API (ADR 0067). The words come from the error lexicon of the vendor, which ships with its adapter package. Core services, contracts and the web app hold no code table; nothing there changes for a new vendor.
This guide is for the author of an adapter package libs/mowdeck-control-<vendor> (for example Husqvarna, Navimow or Worx). It assumes the connection adapter itself exists (Writing a new adapter).
flowchart LR
pkg["libs/mowdeck-control-<vendor><br/>lexicon.py"] -->|"entry point<br/>mowdeck.error_lexicons"| reg["mowdeck-control<br/>ErrorLexiconRegistry"]
reg --> dg["Device-Gateway<br/>gateway.errors.explain"]
dg --> tel["Telemetry<br/>/mowers/{id}/error"]
dg --> notif["Notifications<br/>push text"]
tel --> web["web app, Home Assistant,<br/>widgets"]
Only the Device-Gateway installs adapter packages. It answers lookups from every lexicon registered there; Telemetry and Notifications ask it over NATS and never import a vendor package. The lexicon of a mower is picked in this order:
- the vendor key, when the caller names one (
/error-codes/{vendor}/{code}); - the mower's cloud provider (
MowerState.cloud_provider, which the gateway stamps on every state of acloudmower from its registration, ADR 0066): the lexicon that serves it. Several vendors share the kindcloud, so this is what keeps one vendor's texts off another vendor's mowers. A provider no lexicon serves gets the generic explanation; - the mower's connection kind (
MowerState.connection) throughconnection_kinds, for kinds that belong to one vendor (a Bluetooth bridge, the simulator) and forcloudstates that name no provider (those go to the provider that claims unqualified cloud registrations); - the generic explanation.
So a lexicon lists the connection kinds its adapter registers and, when its cloud provider id differs from the vendor key, the provider ids it serves.
The data model is mowdeck_contracts.v1.ErrorLexicon. One entry per code:
| Field | Meaning |
|---|---|
code |
the code exactly as your adapter puts it into MowerState.error_code
|
aliases |
other spellings of the same code (with a sign, a prefix, upper case) |
category |
the manufacturer-neutral MowerErrorCategory: trapped, obstacle, outside, wheel_slip, lift_tilt, collision, battery, blade_motor, rain, positioning, communication, docking, firmware, other
|
severity |
info (the mower handles it), warning (it stopped and waits), error (needs a repair or support), critical (a safety stop: lifted, tilted) |
user_action |
a person has to do something before the mower carries on |
texts |
per locale an ErrorText: title (short), description, cause (or null), steps (in order); en is required, write de too |
links |
optional https:// links to the vendor's help |
verified |
false when you could not check the meaning against a vendor source |
Rules:
-
Category first. The category drives behaviour beyond the words:
lift_tiltraises the theft alarm (MOWDECK_NOTIFICATIONS_LIFT_CATEGORIES), and the recoverable categories (trapped,obstacle,outside,wheel_slip) are what your adapter marksdrive_recoverable. Useotherwhen unsure, never guesslift_tilt. - One language per entry. If the vendor gives German titles but English steps, leave German out; the lookup falls back to English for the whole entry.
-
Honest provenance. Set
sourceon the lexicon; mark entriesverified: falsethat come from reverse engineering or that you wrote without a vendor source. Clients show such texts with a note. - Write for the user. Titles name the situation ("Lifted", "Wheels slipping"), steps start with a verb, the German uses "du" like the rest of the app.
Keep the lexicon in its own module of the adapter package, for example lexicon.py:
from functools import cache
from mowdeck_contracts.v1 import (
ConnectionKind,
ErrorLexicon,
ErrorLexiconEntry,
ErrorSeverity,
ErrorText,
MowerErrorCategory,
)
from mowdeck_control.error_lexicons import ErrorLexiconProvider
LEXICON = ErrorLexicon(
vendor="acme", # the entry point name, lower case
vendor_name="Acme", # shown to users
connection_kinds=[ConnectionKind.CLOUD],
# only when the provider id (`mowdeck.cloud_providers` entry point) differs from `vendor`:
cloud_providers=["acme-eu", "acme-us"],
source="Acme service manual 2026, chapter 9",
entries=[
ErrorLexiconEntry(
code="E-37",
aliases=["37"],
category=MowerErrorCategory.LIFT_TILT,
severity=ErrorSeverity.CRITICAL,
texts={
"en": ErrorText(
title="Lifted",
description="The lift sensor stopped the blades.",
cause="Someone lifted the mower.",
steps=["Check on the mower now.", "Put it down and press OK on the mower."],
),
"de": ErrorText(
title="Angehoben",
description="Der Hebesensor hat die Messer gestoppt.",
cause="Jemand hat den Mäher hochgehoben.",
steps=["Sieh sofort nach dem Mäher.", "Stell ihn ab und drück OK am Mäher."],
),
},
),
],
)
def normalise(raw: str) -> str:
"""The vendor's raw code in the lexicon's spelling ("e37" gives "E-37")."""
digits = "".join(c for c in raw if c.isdigit())
return f"E-{digits}" if digits else raw.strip()
@cache
def acme_lexicon() -> ErrorLexiconProvider:
return ErrorLexiconProvider(LEXICON, normalise=normalise)The entry point object may be the ErrorLexicon itself (plain data), an ErrorLexiconProvider (data plus normalise) or a callable without arguments returning either. Use a callable when the lexicon is built from a table your vendor library ships (the Mammotion lexicon reads PyMammotion's bundled CSV at the first lookup): importing the package must stay cheap. normalise runs only for a code the lexicon does not know as given, and a failing normalise keeps the code unchanged.
In the adapter package's pyproject.toml, next to the connection factories:
[project.entry-points."mowdeck.connections"]
acme_cloud = "mowdeck_control_acme:create_cloud_connection"
[project.entry-points."mowdeck.error_lexicons"]
acme = "mowdeck_control_acme.lexicon:acme_lexicon"The entry point name must equal ErrorLexicon.vendor; a mismatch is logged and the lexicon skipped. Run uv sync after changing entry points. A new connection kind needs its value in ConnectionKind (mowdeck-contracts) as for any adapter.
Your mapping sets MowerState.error_category whenever it reports activity: error, from the same lexicon, so the state, the app and the rules agree:
from mowdeck_control_acme.lexicon import acme_lexicon
category = acme_lexicon().category_of(raw_code) # None for a code the lexicon does not knowMowerState.raw["lifted"] = True stays the generic lift flag where the vendor reports one directly. With error_category set the theft alarm works for your mower; the core holds no code lists (MOWDECK_NOTIFICATIONS_LIFT_ERROR_CODES and MOWDECK_VISION_AUTO_ERROR_CODES are empty operator overrides). Set drive_recoverable for the recoverable categories, or Vision's automatic analysis never starts for your mower.
In the adapter package's tests:
- every code your mapping can produce is in the lexicon, and
category_ofmatches what the mapping puts intoerror_category; - every entry has English, and German where you wrote it; curated entries have steps;
- the entry point is declared (
tomllibonpyproject.toml) andmowdeck_control.explain_error("<code>", "de", vendor="acme")finds it once the package is installed; - a cloud vendor:
explain_error("<code>", "de", cloud_provider="<provider id>")finds your lexicon, not another one oncloud.
tools/repo-checks/tests/test_vendor_isolation.py fails when an adapter package registers connections without a lexicon, when core code imports an adapter package or its vendor library, or when a service other than the Device-Gateway depends on an adapter package. Install the adapter only in the Device-Gateway image.
With the stack running and a mower of the vendor in an error state:
curl -H "Authorization: Bearer $TOKEN" \
"https://mowdeck.example/api/v1/telemetry/error-lexicons"
curl -H "Authorization: Bearer $TOKEN" \
"https://mowdeck.example/api/v1/telemetry/error-codes/acme/E-37?locale=de"
curl -H "Authorization: Bearer $TOKEN" \
"https://mowdeck.example/api/v1/telemetry/mowers/$MOWER/error?locale=de"The mower page shows the card with severity, category, code, title, cause and steps; the push text of mower_stuck carries the title and the first step.
- Locale fallback to English, and a generic explanation for unknown codes ("Unbekannter Fehler 1234 von Acme" with advice for the state's category).
- Caching (Telemetry and Notifications keep answers for an hour) and graceful degradation when the gateway does not answer.
- The neutral words around the explanation (severity names, labels) in the web app's translations.
Mirrored from docs/dev/error-lexicon.md at commit 14af01c2a57ec5adc34136f7375ca93f2a5b7226. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 14af01c2a57ec5adc34136f7375ca93f2a5b7226. 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
-
Development
- Getting started
- Service template
- Shared libraries
- Cloud providers: adding a vendor
- 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
- API reference
- Original plan (German)