ADR 0026 Manual Drive Channel - lukislp/mowdeck GitHub Wiki
Accepted (2026-10-10)
Phase 3 promises "manual driving with a joystick, blades on" as a direct command through PyMammotion (project plan, feature table). The plan's safety rules for the joystick are a dead-man switch (the command expires after about one second without a signal), blades off during transfers, an emergency stop that is always visible, and sensor stops that are never bypassed (section "Risiken, Sicherheit"). The contracts already have ManualDrive{linear, angular, blade_on, ttl_ms 100..2000}, and the Device-Gateway's MowerController already turns frames into a drive session with a dead-man timer per frame and the blade rules (ADR 0012). Fleet authorizes manual_drive with can_drive and records every command (ADR 0016); Realtime holds one authenticated WebSocket per client and re-checks access (ADR 0018).
A joystick sends about ten samples per second. Sending each one as POST /api/v1/fleet/mowers/{id}/commands would cost an HTTP request with token verification, an OpenFGA check (cached) and a database row per sample: 600 rows per minute of driving, latency spikes from the connection pool, and a command audit that drowns real commands. On the other hand the right to drive must still be decided by Fleet, which owns the mowers and the command audit, and there must be a record of who drove which mower when.
A drive session is opened once over the Realtime socket and recorded by Fleet; the frames go straight to the Device-Gateway.
- The client sends
drive_start{mower_id, blade_on_allowed?}on its authenticated socket. Realtime checkscan_driveon the mower itself (a fast, fail-closed refusaldrive_not_allowed), then looks up the mower's capabilities: Realtime keeps the newestMowerCapabilitiesSnapshotof every mower from theDEVICEstream (an ephemeral consumer ondevice.mower.*.capabilitiesthat starts with the newest message per subject, a new additive optionlast_per_subjectof the service kit'ssubscribe). Withoutmanual_drive(for example the Mammotion cloud transport, ADR 0019) or without known capabilities the answer isdrive_unsupported. Blades are allowed for the session only when the client asks for them and the mower hasmanual_drive_blades. - Realtime asks Fleet with a new core NATS request
fleet.drive.start(DriveStartRequest{on_behalf_of, account_id, mower_id, blade_on_allowed, max_seconds, connection_id}answered withDriveStartReply{started{session_id, account_id, started_at, expires_at} | error{not_found, forbidden, busy, invalid}}). Fleet performs the checks of its command endpoint for that subject (can_drive, 404 semantics for invisible mowers or a foreign account, service principals refused), refuses a second open session of the same mower (busy, serialized with a per-mower transaction lock), and writes adrive_sessionsrow (id, mower, account, subject, start, expiry, blade flag, connection). An open session past its expiry (a crashed Realtime instance never closed it) is closed withexpiredby the next start. A dedicated request instead of aManualDrivezero frame throughfleet.commands.issuekeeps the command audit free of joystick noise and gives the session its own lifecycle. - Realtime answers
drive_started{session_id, max_rate_hz, ttl_ms, max_seconds, blade_on_allowed, capabilities{manual_drive, manual_drive_blades}}. Eachdrive_frame{session_id, seq?, linear, angular, blade_on}becomes oneCommandEnvelopewith aManualDrivecommand (ttl_ms=MOWDECK_REALTIME_DRIVE_TTL_MS, default 1000), published by Realtime directly oncommand.mower.<id>:issued_byis the verified principal,account_idthe mower's account from Fleet's reply,correlation_idisdrive:<session_id>, and the envelope expires afterttl_ms(a late frame is worse than none). Frames are rate limited per session with a token bucket (MOWDECK_REALTIME_DRIVE_MAX_RATE_HZ, default 10, small burst for jitter); excess frames are dropped silently and counted. A frame withseqis acknowledged withdrive_frame_ack{seq}once it is published. -
The controller stays the only safety authority. The Device-Gateway needs no change:
ManualDrivealready flows through command admission (account check, per-mower task) intoMowerController, which re-arms its dead-man timer with every frame, applies the blade rules to every frame and ends the drive on a sensor stop or an emergency stop. Realtime adds no motion rule. When frames stop for any reason (client stop, closed tab, lost network, crashed instance) the mower stops withinttl_ms. -
A session ends on
drive_stop(client_stop), on socket close (socket_closed), when a re-check finds thatcan_drivewas revoked (revoked, triggered byidentity.grants.changed, the periodic re-check and a token refresh), at the token expiry (token_expired, before the 4401 close), afterMOWDECK_REALTIME_DRIVE_MAX_SECONDS(default 600,max_duration), when the mower refuses a frame (a rejected or failed result with the session's correlation id:mower_busywhile a job runs, otherwisemower_refused), or when the same socket starts another session (replaced; one drive session per socket). Ending publishes an explicit zero frame while the last frame is still within itsttl_ms(the mower stops at once instead of afterttl_ms; a stop frame after the dead-man already fired would only reopen a drive session), sendsdrive_ended{session_id, reason, detail}when the socket is still open, and closes Fleet's record withfleet.drive.stop(DriveStopRequest{session_id, reason, ended_at, frames, frames_dropped}). - Errors on the socket (
errorwith a code; the socket stays open):drive_not_allowed,drive_unsupported,drive_busy,drive_unavailable(Fleet or the bus did not answer) anddrive_not_active(a frame or stop for a session the socket does not have). - The emergency stop stays on the normal command path (
POST .../commandswithemergency_stop,can_emergency_stop); the controller lets it overtake everything and it ends the drive session. - Accepted results of drive frames (ten per second) are not fanned out to subscribers; refusals are. Fleet exposes the audit as
GET /api/v1/fleet/mowers/{id}/drive-sessions(can_view), purged with the command retention. Telemetry does not store accepted drive results in its command timeline. Metrics:mowdeck_realtime_drive_sessions(gauge),mowdeck_realtime_drive_frames_total{outcome="published"|"rate_limited"|"failed"},mowdeck_fleet_drive_sessions_total{outcome}.
- Driving costs one Fleet request and two database writes per session instead of per frame; frame latency is one JetStream publish plus the gateway's admission.
- Realtime now publishes commands and sends requests, so it needs publish rights on
command.mower.*and request rights onfleet.drive.*once NATS accounts are restricted. - Fleet still decides who may drive, but a revoked right takes effect at the next Realtime re-check (immediately on
identity.grants.changed, at the latest afterMOWDECK_REALTIME_RECHECK_SECONDS), not per frame. Frames already published keep the mower moving for at mostttl_ms. - A drive needs known capabilities; a mower whose gateway has not reported them yet (offline) cannot be driven, which is the safe default.
- Clients must send frames continuously while the stick is held and should show
drive_endedreasons; a frame rate below one perttl_msstops the mower between frames by design. - Not covered yet: the joystick UI, latency measurements over the Bluetooth bridge (an open question of the plan), and limits per account for concurrent drive sessions.
Mirrored from docs/adr/0026-manual-drive-channel.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.