Repository navigation
ADR 0060 Apple Watch App
Accepted (2026-10-28)
The owner wants MowDeck on the wrist: the mower's state (activity, battery, progress, error, offline), Start with the mower's saved mow defaults, Pause, Station (return to dock) and an emergency stop with a confirmation step, plus a complication with battery and state on the watch face. iOS only; no Wear OS app.
Forces:
- watchOS has no
WKWebView. The Capacitor shell (ADR 0036) cannot be reused; the watch app must be native SwiftUI. - The MowDeck server is LAN-only: reachable at home or through the user's VPN on the iPhone. watchOS has no VPN client of its own. When the paired iPhone is nearby, the watch's own
URLSessiontraffic is routed through the iPhone; whether that path uses the iPhone's VPN tunnel is not documented by Apple and was not verified. Away from the iPhone (LTE watch, or a watch on a Wi-Fi the iPhone knows) a request goes out directly and reaches a LAN-only server only on the home Wi-Fi. - The widgets of ADR 0059 already gave each iPhone a device credential (an API token with
mower.viewandmower.control, 90 days, in the iPhone's Keychain), and the Swift packageMowerWidgetKitalready has the API client, the models and the presentation rules (word, glyph, battery ring, progress) the watch needs. - Commands are not step-up protected (ADR 0057);
emergency_stopneeds onlycan_emergency_stop, which equalscan_view, and Fleet sends it withpriority = emergency(ADR 0049 describes what the adapter does on a stop). - A watch is easy to lose or leave on a table. Whatever it holds can be used by whoever finds it until it locks (wrist detection) or is erased.
- The owner already ships a WatchConnectivity watch app in another project, with proven patterns: activate the session at launch,
sendMessagewith a reply handler for commands,updateApplicationContextfor the latest state, the watch app writing the state into its own App Group container for the complication.
Two designs were weighed:
-
(a) The watch calls the API itself with its own credential, provisioned once from the iPhone over WatchConnectivity (a second device credential,
Apple Watch: <device>). The watch would work while the iPhone app is not running, but it would only reach the LAN-only server through the iPhone anyway (or on the home Wi-Fi), it would add a credential on the device that is easiest to lose, a second credential to rotate and revoke, and token exchange and HTTP code on the watch. -
(b) Relay through the iPhone app. The watch holds no credential and never calls the server. It sends each command to the iPhone app with
WCSession.sendMessage(_:replyHandler:errorHandler:), which wakes the iPhone app in the background if it is not running; the iPhone runs the command and replies with the outcome. State flows to the watch as the application context.
Chosen: (b). The iPhone is the network path to a LAN-only server in every realistic case, so (a) buys little availability; (b) adds no credential and no new attack surface on a lost watch, needs no server change, and reuses the owner's proven WatchConnectivity patterns. The cost: the watch does nothing without its iPhone nearby, and says so ("iPhone nicht erreichbar").
The iPhone side runs natively, not in the web view: a message from the watch can launch the app in the background without a scene, so the Capacitor bridge and the web session (an HttpOnly refresh cookie inside WKWebView) are not available. The relay therefore uses the device credential of ADR 0059 from the iPhone's Keychain with MowerWidgetKit's MowDeckClient (exchanged for 10 minute access tokens, the same per-mower checks in OpenFGA as for the user). Consequences:
-
No new credential and no new scope.
mower.viewcovers state and the emergency stop,mower.controlStart, Pause and Station; nothing on the server changes. -
The watch needs widgets turned on in the iPhone app (Settings -> This app). Without the credential the iPhone answers
not_set_upand the watch shows "Auf dem iPhone einrichten". The settings panel shows the watch's state (no watch paired, app not installed, needs widgets, ready). - Turning widgets off, logging out, or revoking the token in Settings -> API tokens also stops the watch at once; the iPhone sends the watch a "not configured" state.
-
Command (watch -> iPhone,
sendMessagewith a reply handler):{v: 1, op: "command", command: start|pause|dock|emergency_stop, mowerId: <UUID>}. The iPhone ignores another version, another operation, another command and a mower id that is not a UUID (it ends up in a URL path).startstarts all areas with the savedmow_defaults.pattern, exactly like the widget button;dockisreturn_to_dockwithend_job: false. -
Reply:
{v: 1, ok: true}or{v: 1, ok: false, failure: not_set_up|not_allowed|not_found|server_unreachable|bad_request|failed}. The watch addsphone_unreachable(not reachable, nothing sent) andno_answer(the reply timed out: the command may have been sent, check the state). -
Refresh (watch -> iPhone):
{v: 1, op: "refresh"}when the watch app comes to the foreground; the answer is a new application context. -
State (iPhone -> watch):
{v: 1, state: <JSON>}withconfigured,serverReachable,updatedAtand up to eightMowerSnapshots (the widgets' status and schedule shapes). Sent withupdateApplicationContext(latest wins) after every command, on a refresh, when widgets are turned on or off, when the session activates, and on the silentwidget-reloadpush the Notifications service already sends on every state change (ADR 0059). -
Nothing is queued. Commands never use
transferUserInfo: a Start delivered an hour late is not wanted. If the iPhone is unreachable, nothing is sent and the watch says so.
- A confirmation dialog on the watch ("Mäher sofort stoppen?" -> "Not-Stopp" / "Abbrechen"), then
sendMessageat once. When the iPhone does not answer or is not reachable, the watch tries three times within about two seconds (a second stop is harmless; the other commands are sent once). It shows the outcome with a haptic ("Not-Stopp gesendet", or why not). - The button is always offered, also while the iPhone looks unreachable or another command is on its way; only a stop already in flight disables it.
- No step-up, deliberately: stopping a mower must never be slowed by a re-authentication, and it needs only the right to see the mower. A stop cannot unlock, delete or reveal anything. (API tokens could not do a step-up anyway.)
- "Sent" means Fleet accepted the command (202). Whether the mower stopped is the next state; the latency over 4G is the adapter's (ADR 0049, open on hardware).
A WidgetKit extension inside the watch app (accessoryCircular: battery ring with the state glyph; accessoryCorner; accessoryRectangular: name, state, battery, progress or error; accessoryInline: "Mäht · 64 %"). It shows the mower last opened on the watch (else the first), reads only the state the watch app keeps in the watch's own App Group container, and greys out after 15 minutes without a new state. No network, no credential.
Budget. watchOS allows an app about 50 transferCurrentComplicationUserInfo calls a day; they wake the watch app in the background. The iPhone spends one only when the watch face would visibly change (ComplicationPolicy: another state, a battery step of 10 %, a progress step of 25 %, the mower list or the configuration) and never for a refresh the watch asked for; everything else is the application context, which the watch reads the next time its app runs. A mowing day with a state change every few minutes stays well inside the budget; when it is used up, the face updates the next time the watch app opens.
-
MowDeckWatch(watchOS 10, single-target watch app,app.mowdeck.mobile.watchkitapp,WKCompanionAppBundleIdentifier=app.mowdeck.mobile,WKRunsIndependentlyOfCompanionApp= false), embedded inApp.app/Watch;MowDeckWatchWidgets(app.mowdeck.mobile.watchkitapp.Widgets) in itsPlugIns. Both linkMowerWidgetCore(the package now declares watchOS 10; ActivityKit stays iOS-only). Entitlements: the App Groupgroup.app.mowdeck.mobileonly, no Keychain group;ios_release_check.pyfails if the watch targets get any other entitlement. - Text in
Localizable.xcstrings(English and German), SF Symbols and system colours, accessibility labels on the battery gauge and the complication. - iPhone side:
WatchRelay.swift(activated indidFinishLaunching, before a background-launch message is delivered; a background task keeps the command and the state that follows alive),MowDeckShell.getWatchStatusandsyncWatch, and the settings panel in the web app. - TestFlight: two more App IDs and App Store profiles (
APPLE_PROFILE_WATCH_BASE64,APPLE_PROFILE_WATCH_WIDGETS_BASE64);release-ios.shsigns inside out (complication, watch app, iPhone app). The owner's steps are in operations. -
ios_release_check.pynow also checks that every object id in the project file is defined once and referenced ids exist, and that every file of the project's groups and everyINFOPLIST_FILE/CODE_SIGN_ENTITLEMENTSexists. That check found that theMowDeckWidgetsgroup and settings of ADR 0059 pointed intoApp/instead ofapps/mobile/ios/MowDeckWidgets; fixed here.
Options considered and rejected:
- (a) with a watch credential: see above; it can be added later if a watch must work while the iPhone is away from a reachable server, e.g. on the home Wi-Fi only. The design would be a third credential kind with the same two keys, its own token-list entry and revocation, and a shorter lifetime.
-
Queued commands (
transferUserInfo) as a fallback: late, unseen execution of a Start or Station. Rejected; only the state is delivered late. - Step-up for the emergency stop: safety first, see above.
- A Wear OS app: not wanted now.
- The watch works only with the iPhone in Bluetooth or Wi-Fi range (the watch shows "iPhone nicht erreichbar" otherwise) and only where the iPhone reaches the server (at home, or with the VPN on the iPhone). A watch with LTE away from its iPhone shows the last known state and cannot command.
- If the iPhone app was force-quit by the user, iOS may not wake it for watch messages until it is opened again; the watch then reports
phone_unreachableorno_answer. - The watch's values are as fresh as the iPhone's last read: after a command, on opening the watch app, and on the server's silent state pushes (which iOS may throttle, ADR 0059). The watch shows when its state is older than 15 minutes.
- Widgets must be on for the watch: users who do not want widgets on the home screen still turn the feature on (the credential is the same; the widgets need not be added).
- No server change, no migration, no new setting.
- Owner, once: two App IDs, two profiles and two secrets (operations), a TestFlight run, then install the watch app from the Watch app on the iPhone. Nothing of the watch app was compiled: there is no macOS CI (ADR 0036); the first TestFlight run compiles it.
Mirrored from docs/adr/0060-apple-watch-app.md at commit f2b638806535ae0bd3f33714e176f58bf05f35f2. Edit the file in the repository, not this wiki page.
Mirrored from the docs/ directory at commit f2b638806535ae0bd3f33714e176f58bf05f35f2. 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
- Development
-
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
- Source code offer (AGPL-3.0 section 13)
- Spikes
- Guides
- Original plan (German)