Skip to content

ADR 0058 Theft Tracking Mode

github-actions[bot] edited this page Oct 28, 2026 · 3 revisions

ADR 0058: Theft tracking mode

Status

Accepted (2026-10-27)

Context

ADR 0027 raises critical theft alarms (mower_lifted, mower_left_dock_unexpectedly, geofence_exit) with one position, and the Device-Gateway reports a docked or idle Mammotion mower only every few minutes. A thief who carries the mower away needs a different rhythm: a position every few seconds over the 4G link, a way to show the police where it is without handing over the account, and a report to attach to the complaint. The owner decided: tracking starts by itself on a theft alarm and ends by hand ("Fehlalarm / Ortung beenden") or after 24 h; positions every 10 s (both configurable on the server); a secret share link that shows only a live map for 48 h; a PDF theft report.

Decision

Telemetry owns the sessions. It already holds the position track, the mower directory and the mower routes, and it is the one service that reads positions; a new service for three tables would add a deployment, a database and CI jobs without a boundary worth drawing. Tables (migration 0012): tracking_sessions (a partial unique index allows one active session per mower), tracking_links (only the SHA-256 of the token), tracking_photos; mower_directory gains name and serial (the manufacturer's device name from the connection options, the closest to a serial number MowDeck knows). The session stores no positions: its track is the normal position track (mower_positions) between started_at and ended_at, which the gateway fills at the session's interval.

State machine. active until ended_at; end reasons false_alarm, stopped (by hand) and max_duration (MOWDECK_TELEMETRY_TRACKING_MAX_HOURS, 24). Starting is idempotent (a second alarm while tracking changes nothing). Start triggers: the three alarm kinds (Telemetry consumes notifications.raised, durable telemetry-notifications; MOWDECK_TELEMETRY_TRACKING_AUTO_START switches it off) or POST /mowers/{id}/tracking by hand. A background tick every MOWDECK_TELEMETRY_TRACKING_TICK_SECONDS (15) runs on one replica (PostgreSQL advisory lock) and, per active session: ends it at the maximum, follows the position, marks it stale when the mower has not reported for ..._STALE_AFTER_SECONDS (120) or the link is down (the last known position stays; one notice per outage), notices a move of at least 50 m at most every 5 minutes, and renews the gateway lease.

Gateway lease, not a command. Telemetry asks the Device-Gateway that holds the mower, over core NATS gateway.mower.<id>.tracking (TrackingLeaseRequest/Reply), for reports every interval_seconds (10, never below 5) for lease_seconds (60), and renews it on every tick. When Telemetry dies the lease runs out and the mower returns to its normal rhythm, so a crash can never leave a mower polling the vendor cloud for ever. The session applies it through the new optional connection capability TrackingRateCapable (like ReportRateCapable, ADR 0042); the Mammotion adapter gets a track mode in its refresh loop: one undebounced report request per interval (cloud/4G or Bluetooth, whatever link is up), and the gateway heartbeat follows the interval so every report is a track point. A connection that reports on its own (the simulator) needs no port. Replies: ok, not_found (also for a foreign account), not_connected (the next renewal applies it when the mower is back).

Vendor limits. About 0.1 requests per second per tracked mower is far below the 1 per second of manual driving that the vendor server accepted (ADR 0049). The adapter additionally backs off: three consecutive polls without a new report double the wait (cap 120 s), a failed request counts as silent and is logged at warning level (the first and every tenth), a report resets it. The lease is the only way to start it, and it ends by itself.

Notifications. Telemetry publishes tracking.started, .moved, .stale, .ended (event.tracking.*, TrackingEvent); Notifications consumes them (durable notifications-tracking) and creates the kinds tracking_started (critical), tracking_moved, tracking_stale (warning) and tracking_ended (info). All but the end ignore quiet hours; they have no acknowledge silence (the end of the session is the answer) and no cooldown of their own: Telemetry sends no spam (one start, a move at most every 5 minutes, one stale notice per outage, one end). They open /mowers/<id>/tracking; ending a session from the notification itself needs a service worker action and is not part of this change (the page opens with the "false alarm" button one tap away).

Rights follow ADR 0027 and need no model change: reading the session, the links and the report need can_view; starting, ending, taking photos, creating and revoking links and deleting need can_control (a viewer must not publish the location). Acknowledging an alarm needs only the inbox item.

Share link. The owner creates a link for the active or newest session: 256 bits from the CSPRNG, shown once, only its SHA-256 stored, expiry 48 h (..._LINK_TTL_HOURS; a caller may ask for less, never more), at most 10 active links per session, revocable at once, listed with created/expires/uses in the tracking view. The address is /track#<token>: the token sits in the URL fragment, so it reaches no access log, proxy or Referer, and the page strips it from the address bar. The page calls POST /api/v1/telemetry/public/tracking/view (token in the body, no login) and gets exactly: mower name and model, active or ended, start and end time, last position, stale since, the track since the alarm (at most 1500 points), the link's expiry and the server time. No account, no mower id, no serial, no controls, nothing about other mowers. Unknown, expired and revoked tokens are all 404. Rate limit per client (rightmost X-Forwarded-For, else the peer): 60 requests per minute and 20 unusable tokens per minute, then 429 with Retry-After; the limiter is per replica and in memory (the token cannot be guessed, the limit protects the database). Every public answer carries Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, noarchive, Referrer-Policy: no-referrer and a default-src 'none' CSP; the page itself is served by nginx with the same robots and referrer headers and no-store. A link outlives the end of the session until it expires (the police can look at the final track); it dies with the session's deletion.

Theft report. GET /mowers/{id}/tracking/report.pdf?lang=de|en builds a PDF on the server with fpdf2 (pure Python, LGPL-3.0, which is compatible with the AGPL; it brings defusedxml and fonttools and uses Pillow, already in the lock file) and its built-in Helvetica: mower model, serial/device name, MowDeck name, alarm kind and time, last position, the track as vectors on a blank metre grid (no map tiles are fetched or embedded, nothing leaves the server), a position table (at most 45 rows, thinned), up to four camera photos, and blank owner fields (name, address, contact, police report number, insurance). Photos: Telemetry asks for one still image at the start and once more a minute later if the first failed (gateway.mower.<id>.photo with the error-analysis demand, which may wake a sleeping camera through the relay and ignores the Vision budget, ADR 0045; a lifted mower is an error state), plus POST .../tracking/photo by hand (at most 5 per session). It stores its own images in tracking_photos; the camera photo of push notifications (ADR 0056) is a separate store and is not reused.

Privacy and retention. Ended sessions with their links and photos are deleted ..._RETENTION_DAYS (30) after the end by the existing hourly retention job; DELETE .../sessions/{id} deletes an ended session at once. The positions stay in the mower's position track under its own retention (90 days, ADR 0017); a mower removal deletes everything of the mower.

UI. /mowers/{id}/tracking (phone first: large map, status, "Link teilen", "Bericht (PDF)", "Ortung beenden" with the choice false alarm or stop, links list), the public page /track, a shield button and banner on the mower page, German and English texts. The tracking view polls the API every 8 s with after= (no new realtime message); the mower page every 30 s.

Consequences

  • The track depends on the gateway delivering reports: a mower out of coverage shows "stale since" and its last position, the track resumes when it reconnects. Without RTK the positions can be metres off; the report says so.
  • The vendor cloud sees one report request per 10 s per tracked mower for at most 24 h (default). Lowering interval_seconds raises that; the adapter floor is 5 s.
  • Share links are bearer secrets: whoever has the address sees the position until it expires or is revoked. The fragment keeps it out of logs, but not out of the chat where the owner pastes it.
  • The rate limiter is per replica. Several Telemetry replicas multiply the allowance, which is still harmless for 256-bit tokens.
  • The end-to-end test starts a session by hand (the same path an alarm takes); the lift itself cannot be triggered in the Compose stack, so alarm to session is covered by the Notifications and Telemetry integration tests on the bus.
  • Not covered: ending a session from the notification (service worker action), mapping the vendor's real serial number, a realtime message instead of polling, photos on the share page.

Mirrored from docs/adr/0058-theft-tracking-mode.md at commit f2b638806535ae0bd3f33714e176f58bf05f35f2. Edit the file in the repository, not this wiki page.

MowDeck documentation

Clone this wiki locally