ADR 0026 Manual Drive Channel - lukislp/mowdeck GitHub Wiki

ADR 0026: Manual drive channel over the Realtime WebSocket

Status

Accepted (2026-10-10)

Context

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.

Decision

A drive session is opened once over the Realtime socket and recorded by Fleet; the frames go straight to the Device-Gateway.

  1. The client sends drive_start{mower_id, blade_on_allowed?} on its authenticated socket. Realtime checks can_drive on the mower itself (a fast, fail-closed refusal drive_not_allowed), then looks up the mower's capabilities: Realtime keeps the newest MowerCapabilitiesSnapshot of every mower from the DEVICE stream (an ephemeral consumer on device.mower.*.capabilities that starts with the newest message per subject, a new additive option last_per_subject of the service kit's subscribe). Without manual_drive (for example the Mammotion cloud transport, ADR 0019) or without known capabilities the answer is drive_unsupported. Blades are allowed for the session only when the client asks for them and the mower has manual_drive_blades.
  2. 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 with DriveStartReply{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 a drive_sessions row (id, mower, account, subject, start, expiry, blade flag, connection). An open session past its expiry (a crashed Realtime instance never closed it) is closed with expired by the next start. A dedicated request instead of a ManualDrive zero frame through fleet.commands.issue keeps the command audit free of joystick noise and gives the session its own lifecycle.
  3. Realtime answers drive_started{session_id, max_rate_hz, ttl_ms, max_seconds, blade_on_allowed, capabilities{manual_drive, manual_drive_blades}}. Each drive_frame{session_id, seq?, linear, angular, blade_on} becomes one CommandEnvelope with a ManualDrive command (ttl_ms = MOWDECK_REALTIME_DRIVE_TTL_MS, default 1000), published by Realtime directly on command.mower.<id>: issued_by is the verified principal, account_id the mower's account from Fleet's reply, correlation_id is drive:<session_id>, and the envelope expires after ttl_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 with seq is acknowledged with drive_frame_ack{seq} once it is published.
  4. The controller stays the only safety authority. The Device-Gateway needs no change: ManualDrive already flows through command admission (account check, per-mower task) into MowerController, 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 within ttl_ms.
  5. A session ends on drive_stop (client_stop), on socket close (socket_closed), when a re-check finds that can_drive was revoked (revoked, triggered by identity.grants.changed, the periodic re-check and a token refresh), at the token expiry (token_expired, before the 4401 close), after MOWDECK_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_busy while a job runs, otherwise mower_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 its ttl_ms (the mower stops at once instead of after ttl_ms; a stop frame after the dead-man already fired would only reopen a drive session), sends drive_ended{session_id, reason, detail} when the socket is still open, and closes Fleet's record with fleet.drive.stop (DriveStopRequest{session_id, reason, ended_at, frames, frames_dropped}).
  6. Errors on the socket (error with a code; the socket stays open): drive_not_allowed, drive_unsupported, drive_busy, drive_unavailable (Fleet or the bus did not answer) and drive_not_active (a frame or stop for a session the socket does not have).
  7. The emergency stop stays on the normal command path (POST .../commands with emergency_stop, can_emergency_stop); the controller lets it overtake everything and it ends the drive session.
  8. 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}.

Consequences

  • 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 on fleet.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 after MOWDECK_REALTIME_RECHECK_SECONDS), not per frame. Frames already published keep the mower moving for at most ttl_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_ended reasons; a frame rate below one per ttl_ms stops 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.

⚠️ **GitHub.com Fallback** ⚠️