ADR 0034 Linked Cloud Accounts And Device Discovery - lukislp/mowdeck GitHub Wiki
ADR 0034: Linked cloud accounts and device discovery
Status
Accepted (2026-10-11). Amends ADR 0019 (secrets of the cloud adapter, one client per mower) and ADR 0013 (registrations carry no secrets; the gateway fetches them).
Context
Until now a cloud mower was registered with its own Mammotion credentials in connection_options (account, plus a password or a credential_ref), and the user had to type the exact device name. The owner's first tests with the real LUBA 2 AWD X showed the problems:
- One Mammotion account usually holds several devices (mowers, an RTK base station, a pool cleaner); the user does not know the device name PyMammotion expects, and typing it is error prone.
- Each cloud mower opened its own PyMammotion client and logged in separately. A second login with the same account supersedes the first one at the Mammotion cloud (PyMammotion's
login_and_initiate_cloudsays so), so two mowers of one account, or a re-read of the account while a mower is connected, could log each other out; every extra login also adds to the ban risk R1 of spike 0001. - Plain passwords lived unencrypted in Fleet's
mowers.connection_options(ADR 0019 accepted that for Phase 2 with a TODO), and every registration (mower.registered,mower.updated,mower.removed,fleet.mowers.list) carried them: theEVENTSstream keeps those events for 30 days, and Telemetry, Notifications, the Scheduler, the Planner, Vision and Realtime read that stream. - Fleet is AGPL-3.0 and must not import the GPL-3.0 adapter
mowdeck-control-mammotion(ADR 0011); only the Device-Gateway may talk to the vendor cloud. - The same physical mower can appear in two Mammotion accounts (Mammotion's device sharing) and therefore could be registered twice, in two MowDeck accounts, with two gateway sessions steering one machine.
Decision
- Linked accounts in Fleet. A new object
linked_account(id,account_id,providermammotion,email,password_encrypted, cacheddevices,last_synced_at,last_sync_error) belongs to a MowDeck account; an account may link several Mammotion accounts (one per e-mail, compared withlower()). The password is encrypted with AES-256-GCM underMOWDECK_CREDENTIAL_ENCRYPTION_KEY(the scheme of Identity's signing keys, ADR 0015: random nonce, the linked account id as associated data, a version byte and a key id byte;MOWDECK_CREDENTIAL_ENCRYPTION_KEY_PREVIOUSdecrypts during a rotation and the next re-read re-encrypts with the current key). Without the key linked accounts answer 503; withenvironment=prodFleet refuses the development key of.env.example. Managing linked accounts (and seeing e-mail and devices) needscan_manage_mowerson the account. API under/api/v1/fleet/linked-accounts: list, create ({email, password}), get, delete (409linked-account-in-usewhile mowers use it, the mowers inerrors),POST /{id}/sync(re-read, optionally with a new password),GET /{id}/devices,POST /{id}/mowers(bulk registration).last_sync_errorholds canned messages only, never text from the bus; audit rows name the linked account id, not the e-mail. - Discovery over NATS. Fleet never logs in itself: it sends
CloudDiscoverRequest{provider, account_id, linked_account_id?, email, password}on the core NATS subjectgateway.cloud.discover(queue groupdevice-gateway, outside every stream) and getsCloudDiscoverReplywithdevices(DiscoveredDevice:device_name,iot_id,product_key,model,online,is_mower) or an error (login_failedbecomes 422linked-account-login-failed,unavailableand no answer 503). The gateway runs at mostMOWDECK_CLOUD_DISCOVERY_CONCURRENCY(2) logins at once. - Lockout protection. Every create and re-read is a vendor login. Fleet allows at most 5 failed logins per (MowDeck account, lower-case e-mail) within 15 minutes (then 429 with
Retry-After, without asking the vendor), and refuses the last wrong password again at once for 15 minutes (it remembers an HMAC with a per-process key, never the password); a success clears both. The state lives in each Fleet replica's memory. - No secrets in registrations; the gateway pulls them on demand. Registrations never carry
password,noise_pskor the vendoraccount. When Fleet withholds any of them, or the mower uses a linked account, the options saycredentials: "fleet"(andlinked_account_id, andcredentials_rev, a fingerprint of the stored ciphertext that changes with a new password, so the gateway reconnects). Each time the Device-Gateway opens such a mower's connection (first connect, every reconnect, so also after a dead login) it sendsMowerCredentialsRequest{mower_id, account_id}onfleet.mowers.credentials(core NATS, queuefleet) and merges theMowerCredentialsReply.secret_optionsinto the options of that connection only; nothing is kept on the session or logged.MowerRegistration.__repr__masks secret keys as a second line of defence. - One cloud session per account in the gateway.
mowdeck-control-mammotiongets aCloudSessionPool: one logged-inMammotionClientper set of credentials (an HMAC with a random key of the process over the lower-case e-mail and the password; neither is kept in the key or logged), reference counted. Every cloud connection borrows it (ClientPortwith anOpenedClientthat releases instead of stopping), selects its own device handle and keeps its own state, refresh loop, map, camera and device information rounds; the last release stops the client after 120 s (so discovery followed by registering the listed mowers needs one login). A dead login (reauth_required) is replaced on the next acquire; when a login with a new password succeeds, the old password's unused session stops at once and a used one on its last release. The credential cache is one file per address inMOWDECK_CLOUD_SESSION_CACHE_DIR. The discovery uses the same pool: when a mower of the account is connected, the re-read answers from its session instead of logging in again. The gateway is the only service that imports the GPL package directly (the fake cloud module only when it is selected); Fleet depends only on the contracts. - Mowers reference the linked account.
POST /mowersforcloudalways takeslinked_account_idanddevice_name(the only discriminator; name defaults to the device name, model to the listed model); Fleet checks the device in the cached list (unknown 422, not a mower 422), storesdevice_nameandiot_idas Fleet-managed options and the new columnsmowers.linked_account_id(foreign key,RESTRICT) andmowers.iot_id.GET /connection-kindsmarkscloudwithlinked_account: trueand the managed options withmanaged: true. Simulated and BLE bridge registrations are unchanged (the BLE key is withheld from registrations like the password). - One physical mower in one MowDeck account. The unique key is the vendor's stable device id, the IoT id, not the editable device name and not (linked account, device): a device shared between two Mammotion accounts is listed in both and is still one machine.
mowers.iot_idhas an installation-wide unique index; a registration of a taken IoT id answers 409mower-already-registeredwithout naming the other account (in the own account the detail names the existing mower). The device list marks such devicesregistered_elsewherenext toregistered(this account). Removing the mower frees the IoT id. - Existing cloud mowers are not migrated (owner's decision). Migration
0008adds the schema only. Cloud mowers registered before keep connecting with their stored credentials (answered byfleet.mowers.credentials, never in events) until they are deleted; to use the device list they are deleted and added again from a linked account. APATCHmay not introduce a new plain password or change the IoT id (422), and cannot turn another kind intocloud. - Fake cloud for development.
MOWDECK_FAKE_MAMMOTION_CLOUD(default false, refused withenvironment=prod) swaps pool, factory and discovery for the fake cloud of the adapter package: accounts*@fake.mowdeck.testwith the passwordfake-cloud-password, two mowers (online, offline) and an RTK base station, connections onFakeMammotionPort. The browser tests switch it on.
Alternatives considered:
- Fleet talks to the Mammotion HTTP API itself (login and device list are plain HTTPS): rejected, it would put vendor code into the AGPL service and split the vendor knowledge across two services.
- Credentials inside the registrations, as before: rejected, the events are persisted for 30 days and read by services that never need a password.
- The gateway pulls credentials on demand (
fleet.mowers.credentials): chosen. One extra request per connect; Fleet stays the only holder of the secrets and the gateway holds them only inside open connections. - Credentials only as
credential_refin the gateway's environment: kept for mowers that use it, but a household cannot add a Mammotion account through the web app that way. - A separate secret store service: still the long term option (ADR 0019); AES-GCM in Fleet's database with a key outside it is the same protection the Identity and Vision secrets have today.
- Unique key (linked account, device name): rejected, Mammotion's sharing lists one machine under several accounts and device names can be renamed in the app.
- Moving existing cloud mowers automatically: dropped at the owner's request; the few existing mowers are re-added.
Consequences
- Adding a cloud mower is: link the account once (e-mail, password), pick the mowers from the list, name them and choose their sites; several at once.
- Former exposure: until this change the plain passwords of cloud mowers (and the ESPHome keys of BLE bridges) were in every
mower.*event andfleet.mowers.listreply, so they are still in theEVENTSstream of existing installations for up to 30 days and may appear in logs of services that dumped those payloads. Operators who registered real mowers before should change those Mammotion passwords (and ESPHome keys) after the upgrade, or purge the stream. - The mowers of one Mammotion account share one login per gateway instance. With several gateway shards (ADR 0013) mowers of one account can land on different instances, each with its own session, and a discovery may be answered by an instance without a session; both can supersede each other's login. Linked accounts are therefore unsupported with
shard_count > 1; the gateway warns once when it gets such a mower. - The login throttle is per Fleet replica; several replicas multiply the limit by their number.
- The device list is what PyMammotion registered at the session's login: a device added in the Mammotion app appears after the session is opened again (no mower of the account connected for two minutes, or a gateway restart). Unverified against the real cloud: the
onlineflag right after login (PyMammotion'sMowerDevice.onlinedefaults to true until a status message arrives), the model name before the device information rounds, several mowers on oneMammotionClientover MQTT at the same time, and a second login superseding the first one. MOWDECK_CREDENTIAL_ENCRYPTION_KEYmust be backed up: without it the stored passwords cannot be read and every linked account has to be re-read with its password. Helm'sexistingSecretneeds the keysfleet-credential-encryption-keyandfleet-credential-encryption-key-previous(both may be empty).- Migration numbering:
0008follows0007(area names, merged first). - The Home Assistant integration does not register mowers; it only redacts the new
iot_idandlinked_account_idin its diagnostics.
Mirrored from docs/adr/0034-linked-cloud-accounts-and-device-discovery.md at commit 94c46ef36cabac4c3a0bacfc0cc0c0af4defdca3. Edit the file in the repository, not this wiki page.