Repository navigation
ADR 0067 Error Code Lexicon
Accepted (2026-10-29)
A mower reports its faults as vendor codes: Mammotion sends numbers such as -1201, and the adapters for Husqvarna, Navimow and Worx that are being built will bring codes of their own. MowDeck showed them as they came (1201 The robot is stuck): the code, the vendor's terse English text and, since the error category, one neutral word. Users want to know what happened, how bad it is, why and what to do, in German or English, in the app, in the push text and through the API (the Home Assistant integration, the widgets).
Forces:
-
The core stays vendor neutral. Mower control is extended through entry points (
mowdeck.connections, ADR 0012, ADR 0019); the core services and the web app must not hold a vendor's code table. The texts belong to the adapter package that knows the vendor. - Licences and images. Only the Device-Gateway installs adapter packages; the Mammotion adapter is GPL-3.0 and imports PyMammotion (ADR 0011). Telemetry and Notifications must not install it to get at its texts.
- Data at hand. PyMammotion 0.10.9 (pinned) bundles the vendor's own export of the error table: 664 rows, English for all, German for about two thirds (partly machine translated: 2801 "Lift sensor triggered" is "Hub auslösen"), a "solution" text for most, and 19 codes PyMammotion read out of the firmware with its own English words.
-
The theft alarm (ADR 0027) recognises a lift by
MOWDECK_NOTIFICATIONS_LIFT_ERROR_CODES, a list of Mammotion codes in a core service. A second vendor would need its codes in that list. - Graceful. A code no lexicon knows, a vendor without a lexicon, a gateway that does not answer: the user still sees something useful, never an error page.
mowdeck_contracts.v1.error_lexicon defines the data, nothing vendor specific:
-
ErrorLexicon:vendor(lower-case key, the entry point name),vendor_name,connection_kinds(the kinds whose mowers report its codes),source(provenance) and theentries. -
ErrorLexiconEntry:codeandaliases(other spellings such as-1201),category(MowerErrorCategory),severity(info,warning,error,critical),user_action,textsper locale (ErrorText:title,description,cause,steps;enis required,deis MowDeck's second language),links(https only) andverified(false when the meaning could not be checked against a vendor source). -
ErrorExplanation: one code explained in one locale, withknown, the vendor, the texts after the locale fallback, severity, category,user_action, links andverified. -
explain_codeandunknown_explanation: the lookup with the fallbacks. A missing locale falls back to English; an unknown code (or no lexicon) gives "Unknown error 1234 from Mammotion" ("Unbekannter Fehler 1234 von Mammotion") with MowDeck's generic advice for the state's category (generic_text, written per category, no vendor words).
MowerErrorCategory gains communication, docking and firmware, so the categories cover what a lexicon needs (stuck, obstacle, outside, wheel slip, lifted or tilted, collision, battery, blade or motor, rain, positioning, connection, charging station, firmware, other). The new values are additive; the Mammotion mapping now sorts codes that were other into them.
mowdeck_control.error_lexicons loads the entry point group mowdeck.error_lexicons: the name is the vendor key, the object an ErrorLexicon (plain data), an ErrorLexiconProvider (the data plus an optional normalise(raw) -> code function) or a callable without arguments that returns either (built at the first lookup). The registry picks a lexicon by vendor, by the mower's cloud provider (MowerState.cloud_provider, see Picking the lexicon by cloud provider) or by its connection kind (MowerState.connection) through connection_kinds. A broken or misnamed entry point is logged and skipped, like a broken connection adapter. Adapters can also call provider.category_of(code) to fill MowerState.error_category.
[project.entry-points."mowdeck.error_lexicons"]
mammotion = "mowdeck_control_mammotion.lexicon:mammotion_lexicon"-
Mammotion (
mowdeck_control_mammotion.lexicon): built from PyMammotion's bundled table, every code except "Request succeeded" (663 codes). The vendor's implication is the title, its solution is split into steps; German only where the table has a German title and German steps, so no entry mixes languages (475 codes have German). 26 codes MowDeck acts on (stuck, blocked, outside, wheel slip, lifted or tilted, battery, positioning) have curated English and German texts with description, cause and steps. Categories come from the sameerrors.category_ofthat fillsMowerState.error_category, so lexicon and state never disagree. The 19 firmware-only codes areverified: false.normalisedrops the sign. -
Simulator (
mowdeck_control.simulator_lexicon, vendorsimulated): its two codesstuckandlifted, so the whole path can be tested without a mower.
The Device-Gateway is the only service with the adapters installed. It answers two core NATS requests, any instance (queue group device-gateway): gateway.errors.explain (ErrorExplainRequest: up to 20 codes, vendor, cloud provider or connection, locale, category) and gateway.errors.lexicons. It holds no table of its own.
Telemetry is the public API (it already serves the mower state to the web app, the Home Assistant integration and the widgets):
| Route | Permission | Answer |
|---|---|---|
GET /api/v1/telemetry/mowers/{id}/error?locale=de |
can_view |
MowerErrorOut: the current error explained (error null without one) |
GET /api/v1/telemetry/mowers/{id}/error-codes?code=..&code=.. |
can_view |
explanations with the mower's lexicon (the health page's error counts) |
GET /api/v1/telemetry/mowers/{id}/error-lexicon?q=..&category=..&severity=..&code=.. |
can_view |
a page of the lexicon of the mower's vendor (added 2026-10-29, see below) |
GET /api/v1/telemetry/error-codes/{vendor}/{code} |
signed in | one code of a vendor |
GET /api/v1/telemetry/error-lexicons |
signed in | the installed lexicons (counts, unverified, locales) |
The locale comes from locale, else Accept-Language, else English. Telemetry caches answers for an hour; when the gateway does not answer it serves the generic explanation (not cached).
The mower page shows the explanation as a card (severity and category, vendor and code, title, description, likely cause, the steps as a numbered ladder; a hazard stripe for critical errors; unknown and unverified entries are marked) and keeps the old one-line notice while it loads or when Telemetry cannot answer. The health page puts each counted code's title next to it. The push text of mower_stuck ("Fehler 1201. {error_detail}") carries the lexicon's title and first step in the recipient's language; without an answer it keeps the vendor's message as before. The web app only translates the frame (severity names, labels); the vendor words come from the lexicon.
The mower's settings page has an Error codes section: the whole lexicon of the mower's vendor, searchable by code or text (every word must match, in the chosen language and English), filtered by category and severity, a page of 50 at a time, a row opening to the full explanation. Codes the mower has reported (its health counts) carry their count, the current error is pinned and marked, unverified entries are marked. A third core NATS request, gateway.errors.entries (ErrorLexiconSearchRequest / ErrorLexiconSearchReply), returns one page with the counts per category and severity of the whole lexicon; the search itself is the pure search_lexicon of the contract, so every vendor's lexicon behaves alike, and the registry normalises the mower's spelling of a requested code (-1201) first. Telemetry serves it as GET /mowers/{id}/error-lexicon, picking the lexicon by the mower's cloud provider, else its connection; it does not cache the pages (a settings page, filtered per request). The web app stays vendor neutral: it maps the mower's reported codes to the lexicon's spelling through the explanations the server returns, never by its own rules. The section is read-only.
Since the cloud provider plugins (ADR 0066) several vendors share the connection kind cloud, so the kind alone cannot tell their mowers apart: every cloud mower got the first lexicon (by vendor) that lists cloud, a Husqvarna mower Mammotion's texts (its category stayed right, the adapter fills it). The lexicon is now picked in this order, everywhere (ErrorLexiconRegistry.provider):
- an explicit
vendor(the vendor routes); - the mower's cloud provider: the lexicon that serves it (
ErrorLexicon.cloud_providers, optional; empty means the provider whose id equals the vendor key, so a package whose provider and lexicon share a name declares nothing). When no installed lexicon serves the provider the answer is the generic explanation, never another vendor's lexicon on the same kind; - the mower's connection kind through
connection_kinds, as before (Bluetooth bridge, Wi-Fi, simulator, andcloudstates that name no provider); - the generic explanation.
The mower → provider mapping stays where the registration is. Fleet sets the registration option cloud_provider from the linked account; the Device-Gateway resolves it per registration (the option, else the enabled provider whose spec claims unqualified_cloud, exactly like it picks the plugin) and stamps it on every state it publishes as MowerState.cloud_provider (gateway owned like heartbeat_at; null for other kinds). Telemetry and Notifications read it from the state they already receive and pass it in ErrorExplainRequest.cloud_provider and ErrorLexiconSearchRequest.cloud_provider; neither reads Fleet's database nor resolves anything itself. A cloud request without a provider (a state stored before the field) is treated by the gateway as a mower of the provider that claims unqualified cloud registrations, so existing Mammotion mowers keep their texts before and after their next state. Their caches are keyed by provider too.
Alternatives: the gateway resolving the provider by mower id (an ErrorExplainRequest.mower_id) was rejected because any gateway instance answers lookups (queue group) while only the owning shard holds the registration; asking Fleet per lookup would add a round trip to every explanation.
Notifications counts a state as lifted when raw.lifted is true or its error_category is in MOWDECK_NOTIFICATIONS_LIFT_CATEGORIES (default lift_tilt). Both signals are provider neutral: a vendor's adapter sets error_category from its lexicon and needs no codes in a core setting. MOWDECK_NOTIFICATIONS_LIFT_ERROR_CODES stays as an optional operator override (a code listed there counts too) and is empty by default; it used to list the simulator's lifted and Mammotion's lift and tilt codes 1004, 1030, 2502, 2801, 11131 and 11133. The Mammotion adapter reports exactly these codes with error_category lift_tilt and raw.lifted (its tests check it on the state as it travels over the bus), so the alarm fires for the same states as before.
The automatic AI analysis on errors (ADR 0035) follows the same rule: it starts on drive_recoverable, which the adapters set for the recoverable categories (trapped, obstacle, outside, wheel slip); MOWDECK_VISION_AUTO_ERROR_CODES is an optional override, empty by default (it used to list Mammotion's recoverable codes, which the adapter marks drive_recoverable). The architecture test fails when a core service's settings default to an error code list again.
-
Tables in the core or the web app (a JSON per vendor in
apps/web): rejected, it couples the core to every vendor and duplicates what the adapter knows. - Telemetry installs the adapter packages: rejected, it would put PyMammotion (GPL) and every vendor SDK into a second image for a few texts.
-
Lexicons as snapshots on the
DEVICEstream, stored by Telemetry: no request per lookup, but the Mammotion lexicon in two languages is about half a megabyte per message and a mapping function cannot travel as data. Request/reply with a cache is simpler. -
Explanations inside every
MowerState: rejected; the state would carry texts in every language with every heartbeat.
- A new vendor adds its lexicon in its own package and one entry point; core, contracts and the web app stay unchanged (Adding an error lexicon for a vendor). An architecture test (
tools/repo-checks/tests/test_vendor_isolation.py) fails when core code imports an adapter package or its vendor library, when a service other than the Device-Gateway depends on one, or when an adapter package registers connections without a lexicon. The existing exception, the linked cloud accounts of ADR 0034, is listed in the test. - Explanations depend on the Device-Gateway being up; without it the app shows the generic advice and the push the vendor's message.
- The Mammotion texts follow PyMammotion's table: upgrading PyMammotion (deliberately, ADR 0019) refreshes them; the curated codes are checked by the adapter's tests.
-
MowerErrorCategoryhas three new values: clients with an exhaustive list (the Home Assistant integration's enum sensor) must accept them. - The widgets endpoint (
GET /mowers/{id}/widget) keepserror_codeanderror_messageand addserror_title,error_categoryanderror_severity, resolved through the lexicon in the requested locale, so the iOS widgets, the Live Activity and the watch show the plain-language title from their stored snapshot without a second request. The Live Activity's finalerrorupdate carrieserrorTitlein each recipient's notification language. All new fields are optional; older app builds ignore them.
Mirrored from docs/adr/0067-error-code-lexicon.md at commit 20efce891a6b21e5a427244130b6c0ff457f47e9. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit 20efce891a6b21e5a427244130b6c0ff457f47e9. 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
- 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)
- Scripting MowDeck
- API reference
- Original plan (German)