{"openapi":"3.1.0","info":{"title":"MotionApi Public API","version":"1.0.0","description":"Public REST + streaming API for MotionApi GPS trackers.\n\n### Authentication\n\n`Authorization: Bearer mak_{live|test}_<8-char key id>_<32-char secret>`.\nIssue and rotate keys in the user panel under **API Access → Keys**\n(<https://app.motionapi.pro/api>).\n\nBrowsers cannot set request headers on `EventSource` or on a native\n`WebSocket`, so `?token=<key>` is accepted as a fallback on every endpoint.\nThe `Authorization` header always wins when both are present.\n`WS /v1/stream` accepts an API key **only** — a user-panel JWT is rejected\nwith WebSocket close code `4001`.\n\n### Scopes\n\nEach operation declares the scope it requires in its `security` entry.\nFive scopes gate the surface today:\n\n| Scope | Grants |\n|---|---|\n| `read:devices` | list devices, device metadata, read the active mode |\n| `read:last` | last cached packet per topic |\n| `read:stream` | `WS /v1/stream` and the SSE stream |\n| `write:commands` | `POST /v1/devices/{iccid}/commands`, `POST /v1/devices/_all/commands` |\n| `write:mode` | `PUT /v1/devices/{iccid}/mode`, `PUT /v1/devices/_all/mode`, `PUT /v1/devices/{iccid}/sport`, `PUT /v1/devices/_all/sport` |\n\nNew keys are created with `read:devices`, `read:stream`, `read:last` only —\nthe two write scopes must be granted explicitly. The remaining scopes\noffered by the key editor (`read:positions`, `read:status`, `read:events`,\n`read:imu`, `write:ota`) gate no `/v1` route yet; requesting them changes\nnothing.\n\nA key missing a scope gets `403` with `details.missing`. On the WebSocket\nthe equivalent failure is a close with code `4003` — there is no HTTP body.\n\n### Rate limits\n\n**600 requests/minute per key** by default, counted per endpoint (each\nroute has its own bucket). Keys can be given a higher ceiling on request —\nthe current limit is echoed in the `x-ratelimit-limit` response header,\nwith `x-ratelimit-remaining` / `x-ratelimit-reset` alongside. Only the\nauthenticated request counts: a `401` is never charged to a key. The\n`_all` fan-out endpoints count as **one** request regardless of how many\ndevices they reach, so prefer them over looping per ICCID. On `429` honour\n`Retry-After` and retry with exponential backoff rather than assuming a\nfixed ceiling.\n\n### Response envelope\n\nSuccess is `{ \"success\": true, \"data\": … }`, optionally with `meta`.\nErrors are `{ \"success\": false, \"error\": { \"code\", \"message\", \"details\"? } }`\nwhere `code` is one of `bad_request` (400), `unauthorized` (401),\n`forbidden` (403), `not_found` (404), `mode_disabled` (409),\n`rate_limited` (429), `internal_error` (500). Two exceptions worth coding\naround: `401` bodies carry `error` as a **plain string**, and request-schema\nvalidation failures are returned in Fastify’s own\n`{ statusCode, code, error, message }` shape with no `success` key.\n\n### Packet representations\n\n* `format=raw` — the exact bytes received from the device (base64 buffer +\n  per-frame metadata: message type, payload length, byte offset and a\n  recomputed Fletcher-8 result). One MQTT message can carry several\n  concatenated frames.\n* `format=formatted` — human-readable JSON in SI units.\n\nBoth representations carry `device_mode` (the active preset, expanded).\nREST and SSE accept these two values only; `WS /v1/stream` additionally\naccepts `format: \"both\"` on a `subscribe` message, which emits the raw view\nfollowed by every formatted view of the same packet under one shared `seq`.\n\nDecoding `format=raw` requires the MA binary frame layout — see the binary\nprotocol reference linked below.\n\n### Topics\n\nCurrent firmware publishes on exactly two uplink topics: `bin` (**all**\ntelemetry — batched GPS, status and MARK, as binary frames) and `response`\n(command / OTA acknowledgements, JSON). Subscribe to `bin` and dispatch on\n`payload.kind` (`gps_batch` | `status` | `mark`).\n\nThe `gps`, `status`, `event` and `imu` topic names are accepted only as\nvestigial back-compat — no shipping device publishes any of them (`imu` never\nhad a firmware publisher at all), so they read back as `data: null` and are\nnot an alternative to `bin`.\n\n### Paddling cadence\n\nGPS and MARK readings may carry `cadence_spm` — paddling cadence in strokes\nper minute, present on both `format=raw` (trailing byte at offset 24 of each\n25-byte GPS record) and `format=formatted` (`cadence_spm` on the GPS object).\nIt counts **every blade entry** (sprint-kayak convention): divide by 2 for\nkayak stroke cycles/min, use as-is for canoe. Non-zero values are clamped to\n28–165.\n\n`0` is ambiguous — it means the sport profile is off (profile `0`, the\ndefault), or the device is not currently paddling, or the IMU is\nunavailable. The three are indistinguishable. The field is **absent**\nentirely on firmware still sending 24-byte GPS records; detect presence from\nthe record size on the wire, never from `fw_version` (that value is\n`(major << 8) | minor`, the patch component is dropped, and it was not\nbumped when cadence was added).\n\nCadence is off by default and is enabled by the **sport profile**:\n`PUT /v1/devices/{iccid}/sport` with `{\"sport_id\": 1|2|3}` (`1 Canoe`,\n`2 Kayak`, `3 Paddle (auto)`; `0 None` turns it back off), or\n`PUT /v1/devices/_all/sport` for every device at once — both need the\n`write:mode` scope. The catalogue is at `GET /v1/devices/_sports`. The\nprofile is persisted on the device and is orthogonal to the mode preset.\nIt is **write-only**: nothing reports the active profile back, so remember\nwhat you sent. `cmd: \"config\"` on `POST …/commands` stays rejected — use the\ndedicated endpoint. Cadence is also not persisted: it is only available on\nlive and last-packet reads, never in history.\n\n### Real-time\n\nReal-time data for API-key holders is served by `WS /v1/stream` and\n`SSE /v1/devices/{iccid}/stream`. Direct MQTT broker access is not part of\nthis API; dedicated read-only broker credentials are arranged separately for\npartner integrations.","contact":{"name":"MotionApi support","email":"contact@motionapi.pro","url":"https://motionapi.pro"}},"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","bearerFormat":"mak_<env>_<key-id>_<secret>","description":"API key. `<env>` is `live` or `test`, `<key-id>` is 8 chars, `<secret>` is 32 chars; the first three segments together form the `key_prefix` shown in the panel. Issue and rotate keys under **API Access → Keys** (https://app.motionapi.pro/api). Where a header cannot be set (EventSource, native WebSocket) pass the same value as `?token=`. Scopes listed per operation are enforced for API keys only — a user-panel JWT session is accepted on the REST routes and bypasses scope checks entirely."}},"responses":{"Unauthorized":{"description":"Missing, malformed, revoked, expired or disabled credentials.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"const":false},"error":{"type":"string","description":"A plain string on 401 — NOT the `{ code, message }` object used by every other error status. One of: `Unauthorized`, `Missing Bearer token`, `Invalid API key format`, `Invalid API key`, `API key revoked`, `API key expired`, `User account disabled`. Handle both shapes: `typeof body.error === \"string\" ? body.error : body.error.code`."}}}}}},"Forbidden":{"description":"Authenticated, but the API key lacks the scope this operation declares, the caller lacks the required permission on the device, or the key’s device whitelist excludes it.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"const":false},"error":{"type":"object","properties":{"code":{"const":"forbidden"},"message":{"type":"string"},"details":{"type":"object","description":"Present on scope failures only. Device-permission failures carry no details.","properties":{"required":{"type":"array","items":{"type":"string"}},"granted":{"type":"array","items":{"type":"string"}},"missing":{"type":"array","items":{"type":"string"}}}}}}}}}}},"TooManyRequests":{"description":"Rate limit exceeded. Back off using `Retry-After`.","headers":{"retry-after":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}},"x-ratelimit-limit":{"description":"Requests allowed in the current window.","schema":{"type":"integer"}},"x-ratelimit-remaining":{"description":"Requests left in the current window.","schema":{"type":"integer"}},"x-ratelimit-reset":{"description":"Seconds until the window resets.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"anyOf":[{"type":"object","description":"Envelope form, emitted by the /v1 per-key limiter. Also carries `statusCode: 429` (required by the limiter to set the HTTP status).","properties":{"statusCode":{"const":429},"success":{"const":false},"error":{"type":"object","properties":{"code":{"const":"rate_limited"},"message":{"type":"string"},"details":{"type":"object","properties":{"retry_after_ms":{"type":"integer","description":"Milliseconds until the bucket refills."}}}}}}},{"type":"object","description":"Fastify’s built-in shape, emitted by the host-wide per-IP limiter. No `success` key.","properties":{"statusCode":{"const":429},"error":{"type":"string"},"message":{"type":"string"}}}]}}}}},"schemas":{}},"paths":{"/v1/devices":{"get":{"summary":"List devices accessible to the caller","tags":["devices"],"security":[{"apiKey":["read:devices"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"array","items":{"type":"object","properties":{"iccid":{"type":"string"},"name":{"type":["null","string"]},"hardware_rev":{"type":["null","string"]},"firmware_ver":{"type":["null","string"]},"status":{"type":["null","string"]},"claimed_at":{"type":["null","string"],"format":"date-time"},"cached_status":{"type":["null","object"],"properties":{"position":{"type":["null","object"],"properties":{"lat":{"type":"number"},"lon":{"type":"number"}}},"speed":{"type":["null","number"]},"heading":{"type":["null","number"],"description":"Course over ground in degrees. Only the legacy JSON GPS path populates it — always null for devices on the current binary protocol, which does not transmit heading."},"altitude":{"type":["null","number"]},"battery_v":{"type":["null","number"]},"rssi":{"type":["null","number"]},"rsrp":{"type":["null","number"]},"lastSeen":{"type":["null","string"],"format":"date-time"},"online":{"type":"boolean"},"mode":{"type":["null","integer"]},"is_moving":{"type":["null","boolean"]},"temp_c":{"type":["null","number"]},"rat":{"type":["null","string"]},"operator":{"type":["null","string"]}}},"current_mode":{"type":["null","object"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]},"description":{"type":"string"}}}}}},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}":{"get":{"summary":"Get device metadata + cached snapshot","tags":["devices"],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["read:devices"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"iccid":{"type":"string"},"name":{"type":["null","string"]},"hardware_rev":{"type":["null","string"]},"firmware_ver":{"type":["null","string"]},"status":{"type":["null","string"]},"claimed_at":{"type":["null","string"],"format":"date-time"},"cached_status":{"type":["null","object"],"properties":{"position":{"type":["null","object"],"properties":{"lat":{"type":"number"},"lon":{"type":"number"}}},"speed":{"type":["null","number"]},"heading":{"type":["null","number"],"description":"Course over ground in degrees. Only the legacy JSON GPS path populates it — always null for devices on the current binary protocol, which does not transmit heading."},"altitude":{"type":["null","number"]},"battery_v":{"type":["null","number"]},"rssi":{"type":["null","number"]},"rsrp":{"type":["null","number"]},"lastSeen":{"type":["null","string"],"format":"date-time"},"online":{"type":"boolean"},"mode":{"type":["null","integer"]},"is_moving":{"type":["null","boolean"]},"temp_c":{"type":["null","number"]},"rat":{"type":["null","string"]},"operator":{"type":["null","string"]}}},"current_mode":{"type":["null","object"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]},"description":{"type":"string"}}}}},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}/mode":{"get":{"summary":"Get the device’s active mode preset","tags":["mode"],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["read:devices"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"active":{"type":["object","null"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]},"description":{"type":"string"},"gps_send_interval_ms":{"type":"integer"},"status_interval_ms":{"type":"integer"},"imu_enabled":{"type":"boolean"},"unknown":{"type":"boolean","description":"Present (and `true`) only when the device reported a mode id this backend has no preset for; in that case `id` is the only other field."}}}}},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}},"put":{"summary":"Change the device’s active mode preset","tags":["mode"],"description":"Publishes `{cmd:\"config\", mode:<id>}` to `c/{iccid}/config`. Device-side switch is async — the next status packet confirms.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["mode_id"],"properties":{"mode_id":{"type":"integer","description":"One of the IDs from GET /v1/devices/_modes."}}}}}},"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["write:mode"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"msg_id":{"type":"string"},"requested_mode":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]},"description":{"type":"string"},"gps_send_interval_ms":{"type":"integer"},"status_interval_ms":{"type":"integer"},"imu_enabled":{"type":"boolean"},"unknown":{"type":"boolean","description":"Present (and `true`) only when the device reported a mode id this backend has no preset for; in that case `id` is the only other field."}}},"note":{"type":"string"}}},"meta":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}/last":{"get":{"summary":"Last cached packet for each topic","tags":["snapshots"],"description":"Returns the most recent packet received on every topic this device has emitted. Map keyed by topic name; values follow PacketView shape (raw or formatted depending on `?format`).","parameters":[{"schema":{"type":"string","enum":["raw","formatted"],"default":"formatted"},"in":"query","name":"format","required":false},{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["read:last"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","additionalProperties":true},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}/last/{topic}":{"get":{"summary":"Last cached packet for a specific topic","tags":["snapshots"],"description":"Returns the most recent packet cached for this device on the given topic. Response shape depends on `?format`: `raw` yields a single `RawPacketView` object; `formatted` yields an array of `FormattedPacketView` (a single `/bin` frame can decode into multiple human-readable views, e.g. `gps_batch + status_binary`). Returns `data: null` if nothing has been received on this topic yet.","parameters":[{"schema":{"type":"string","enum":["raw","formatted"],"default":"formatted"},"in":"query","name":"format","required":false},{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true},{"schema":{"type":"string","enum":["gps","status","event","response","imu","bin"]},"in":"path","name":"topic","required":true,"description":"Uplink topic. Current firmware publishes only `bin` (all telemetry — GPS, status and mark — as binary frames) and `response` (command/OTA acks). `gps`, `status`, `event` and `imu` are legacy JSON topics the backend still accepts for backwards compatibility; no shipping device produces them, so they stay empty for real devices."}],"security":[{"apiKey":["read:last"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"oneOf":[{"type":"null"},{"type":"object","properties":{"type":{"enum":["packet"]},"iccid":{"type":"string"},"topic":{"type":"string","enum":["gps","status","event","response","imu","bin"],"description":"Uplink topic. Current firmware publishes only `bin` (all telemetry — GPS, status and mark — as binary frames) and `response` (command/OTA acks). `gps`, `status`, `event` and `imu` are legacy JSON topics the backend still accepts for backwards compatibility; no shipping device produces them, so they stay empty for real devices."},"format":{"enum":["raw"]},"received_at":{"type":"string","format":"date-time"},"device_mode":{"type":["object","null"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]}}},"raw":{"type":"object","properties":{"buffer_b64":{"type":"string"},"size":{"type":"integer"},"frames":{"type":"array","items":{"type":"object","properties":{"msg_type":{"type":"integer"},"msg_type_name":{"type":"string","enum":["GPS","STATUS","IMU","MARK","UNKNOWN"]},"length":{"type":"integer","description":"Payload length in bytes, excluding the 7-byte frame overhead."},"checksum_ok":{"type":"boolean","description":"Always true — this projection reports frame boundaries only and does not re-verify the Fletcher-8 checksum. Recompute it from `raw.buffer_b64` if you need certainty."},"offset":{"type":"integer","description":"Byte offset of the frame inside `raw.buffer_b64`; one MQTT message can carry several concatenated frames."}}}}}}}},{"type":"object","properties":{"type":{"enum":["packet"]},"iccid":{"type":"string"},"topic":{"type":"string","enum":["gps","status","event","response","imu","bin"],"description":"Uplink topic. Current firmware publishes only `bin` (all telemetry — GPS, status and mark — as binary frames) and `response` (command/OTA acks). `gps`, `status`, `event` and `imu` are legacy JSON topics the backend still accepts for backwards compatibility; no shipping device produces them, so they stay empty for real devices."},"format":{"enum":["formatted"]},"received_at":{"type":"string","format":"date-time"},"device_mode":{"type":["object","null"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]}}},"payload":{"type":"object","properties":{"kind":{"type":"string","enum":["gps","gps_batch","status","event","response","imu","imu_batch","mark"],"description":"Live kinds are `gps_batch`, `status`, `mark` and `response`. `gps`, `event` and `imu` are produced only from the legacy JSON path. `imu_batch` is never emitted — binary message type 0x03 is reserved in the protocol but no firmware encodes it."},"data":{"description":"Shape depends on `kind`: `gps_batch` → array of GPS readings, `gps` and `mark` → a single GPS reading, `status` → device status. `response` carries the command/OTA ack object as received. `event`, `imu` and `imu_batch` are legacy-only and passed through unmodelled.","anyOf":[{"type":"array","items":{"type":"object","additionalProperties":true,"required":["timestamp","lat","lon","speed_ms","speed_kmh","heading_deg","altitude_m"],"properties":{"timestamp":{"type":"string","format":"date-time"},"lat":{"type":"number","description":"Degrees."},"lon":{"type":"number","description":"Degrees."},"speed_ms":{"type":"number","description":"3D speed in m/s (not ground speed)."},"speed_kmh":{"type":"number","description":"Derived from `speed_ms`."},"heading_deg":{"type":"number","description":"Course over ground. Always 0 for binary-protocol records — the device does not transmit heading."},"altitude_m":{"type":"number","description":"Metres above mean sea level."},"altitude_baro_m":{"type":"number"},"hdop":{"type":"number"},"satellites":{"type":"integer"},"fix_type":{"type":"integer"},"h_accuracy_m":{"type":"number","description":"Horizontal accuracy in metres."},"battery_v":{"type":"number"},"cadence_spm":{"type":"integer","minimum":0,"maximum":255,"description":"Paddling cadence in strokes per minute, counting every blade entry (sprint-kayak convention): divide by 2 for kayak stroke cycles/min, use as-is for canoe. Non-zero values are clamped to 28–165. `0` is ambiguous and means any of: the sport profile is off (profile 0, the default), the device is not currently paddling, or the IMU is unavailable — the three are indistinguishable. The field is absent entirely on firmware that still sends 24-byte GPS records; presence is detected from the record size on the wire, never from `fw_version` (that value is `(major << 8) | minor`, the patch component is dropped, and it was not bumped when cadence was added). The sport profile that enables the detector cannot currently be set through /v1 — POST /v1/devices/{iccid}/commands rejects `cmd: \"config\"`."}}}},{"type":"object","additionalProperties":true,"required":["timestamp","lat","lon","speed_ms","speed_kmh","heading_deg","altitude_m"],"properties":{"timestamp":{"type":"string","format":"date-time"},"lat":{"type":"number","description":"Degrees."},"lon":{"type":"number","description":"Degrees."},"speed_ms":{"type":"number","description":"3D speed in m/s (not ground speed)."},"speed_kmh":{"type":"number","description":"Derived from `speed_ms`."},"heading_deg":{"type":"number","description":"Course over ground. Always 0 for binary-protocol records — the device does not transmit heading."},"altitude_m":{"type":"number","description":"Metres above mean sea level."},"altitude_baro_m":{"type":"number"},"hdop":{"type":"number"},"satellites":{"type":"integer"},"fix_type":{"type":"integer"},"h_accuracy_m":{"type":"number","description":"Horizontal accuracy in metres."},"battery_v":{"type":"number"},"cadence_spm":{"type":"integer","minimum":0,"maximum":255,"description":"Paddling cadence in strokes per minute, counting every blade entry (sprint-kayak convention): divide by 2 for kayak stroke cycles/min, use as-is for canoe. Non-zero values are clamped to 28–165. `0` is ambiguous and means any of: the sport profile is off (profile 0, the default), the device is not currently paddling, or the IMU is unavailable — the three are indistinguishable. The field is absent entirely on firmware that still sends 24-byte GPS records; presence is detected from the record size on the wire, never from `fw_version` (that value is `(major << 8) | minor`, the patch component is dropped, and it was not bumped when cadence was added). The sport profile that enables the detector cannot currently be set through /v1 — POST /v1/devices/{iccid}/commands rejects `cmd: \"config\"`."}}},{"type":"object","additionalProperties":true,"required":["timestamp","battery_v","rssi_dbm","rsrp_dbm","rsrq_db","mcc","mnc","cell_id","tac","earfcn"],"properties":{"timestamp":{"type":"string","format":"date-time"},"battery_v":{"type":"number"},"rssi_dbm":{"type":"number"},"rsrp_dbm":{"type":"number"},"rsrq_db":{"type":"number"},"mcc":{"type":"integer"},"mnc":{"type":"integer"},"cell_id":{"type":"integer"},"tac":{"type":"integer"},"earfcn":{"type":"integer"},"fix_type":{"type":"integer"},"satellites":{"type":"integer"},"fw_version":{"type":"string","description":"`major.minor` only — the wire field is `(major << 8) | minor` and the patch component never leaves the device, so this must not be used for feature detection."},"mode":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]}}},"is_moving":{"type":"boolean"},"temp_c":{"type":"number","description":"Ambient temperature in °C; absent when the device reported no reading."},"operator":{"type":"string"},"rat":{"type":"string"}}},{}]}}}}},{"type":"array","items":{"oneOf":[{"type":"object","properties":{"type":{"enum":["packet"]},"iccid":{"type":"string"},"topic":{"type":"string","enum":["gps","status","event","response","imu","bin"],"description":"Uplink topic. Current firmware publishes only `bin` (all telemetry — GPS, status and mark — as binary frames) and `response` (command/OTA acks). `gps`, `status`, `event` and `imu` are legacy JSON topics the backend still accepts for backwards compatibility; no shipping device produces them, so they stay empty for real devices."},"format":{"enum":["raw"]},"received_at":{"type":"string","format":"date-time"},"device_mode":{"type":["object","null"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]}}},"raw":{"type":"object","properties":{"buffer_b64":{"type":"string"},"size":{"type":"integer"},"frames":{"type":"array","items":{"type":"object","properties":{"msg_type":{"type":"integer"},"msg_type_name":{"type":"string","enum":["GPS","STATUS","IMU","MARK","UNKNOWN"]},"length":{"type":"integer","description":"Payload length in bytes, excluding the 7-byte frame overhead."},"checksum_ok":{"type":"boolean","description":"Always true — this projection reports frame boundaries only and does not re-verify the Fletcher-8 checksum. Recompute it from `raw.buffer_b64` if you need certainty."},"offset":{"type":"integer","description":"Byte offset of the frame inside `raw.buffer_b64`; one MQTT message can carry several concatenated frames."}}}}}}}},{"type":"object","properties":{"type":{"enum":["packet"]},"iccid":{"type":"string"},"topic":{"type":"string","enum":["gps","status","event","response","imu","bin"],"description":"Uplink topic. Current firmware publishes only `bin` (all telemetry — GPS, status and mark — as binary frames) and `response` (command/OTA acks). `gps`, `status`, `event` and `imu` are legacy JSON topics the backend still accepts for backwards compatibility; no shipping device produces them, so they stay empty for real devices."},"format":{"enum":["formatted"]},"received_at":{"type":"string","format":"date-time"},"device_mode":{"type":["object","null"],"properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]}}},"payload":{"type":"object","properties":{"kind":{"type":"string","enum":["gps","gps_batch","status","event","response","imu","imu_batch","mark"],"description":"Live kinds are `gps_batch`, `status`, `mark` and `response`. `gps`, `event` and `imu` are produced only from the legacy JSON path. `imu_batch` is never emitted — binary message type 0x03 is reserved in the protocol but no firmware encodes it."},"data":{"description":"Shape depends on `kind`: `gps_batch` → array of GPS readings, `gps` and `mark` → a single GPS reading, `status` → device status. `response` carries the command/OTA ack object as received. `event`, `imu` and `imu_batch` are legacy-only and passed through unmodelled.","anyOf":[{"type":"array","items":{"type":"object","additionalProperties":true,"required":["timestamp","lat","lon","speed_ms","speed_kmh","heading_deg","altitude_m"],"properties":{"timestamp":{"type":"string","format":"date-time"},"lat":{"type":"number","description":"Degrees."},"lon":{"type":"number","description":"Degrees."},"speed_ms":{"type":"number","description":"3D speed in m/s (not ground speed)."},"speed_kmh":{"type":"number","description":"Derived from `speed_ms`."},"heading_deg":{"type":"number","description":"Course over ground. Always 0 for binary-protocol records — the device does not transmit heading."},"altitude_m":{"type":"number","description":"Metres above mean sea level."},"altitude_baro_m":{"type":"number"},"hdop":{"type":"number"},"satellites":{"type":"integer"},"fix_type":{"type":"integer"},"h_accuracy_m":{"type":"number","description":"Horizontal accuracy in metres."},"battery_v":{"type":"number"},"cadence_spm":{"type":"integer","minimum":0,"maximum":255,"description":"Paddling cadence in strokes per minute, counting every blade entry (sprint-kayak convention): divide by 2 for kayak stroke cycles/min, use as-is for canoe. Non-zero values are clamped to 28–165. `0` is ambiguous and means any of: the sport profile is off (profile 0, the default), the device is not currently paddling, or the IMU is unavailable — the three are indistinguishable. The field is absent entirely on firmware that still sends 24-byte GPS records; presence is detected from the record size on the wire, never from `fw_version` (that value is `(major << 8) | minor`, the patch component is dropped, and it was not bumped when cadence was added). The sport profile that enables the detector cannot currently be set through /v1 — POST /v1/devices/{iccid}/commands rejects `cmd: \"config\"`."}}}},{"type":"object","additionalProperties":true,"required":["timestamp","lat","lon","speed_ms","speed_kmh","heading_deg","altitude_m"],"properties":{"timestamp":{"type":"string","format":"date-time"},"lat":{"type":"number","description":"Degrees."},"lon":{"type":"number","description":"Degrees."},"speed_ms":{"type":"number","description":"3D speed in m/s (not ground speed)."},"speed_kmh":{"type":"number","description":"Derived from `speed_ms`."},"heading_deg":{"type":"number","description":"Course over ground. Always 0 for binary-protocol records — the device does not transmit heading."},"altitude_m":{"type":"number","description":"Metres above mean sea level."},"altitude_baro_m":{"type":"number"},"hdop":{"type":"number"},"satellites":{"type":"integer"},"fix_type":{"type":"integer"},"h_accuracy_m":{"type":"number","description":"Horizontal accuracy in metres."},"battery_v":{"type":"number"},"cadence_spm":{"type":"integer","minimum":0,"maximum":255,"description":"Paddling cadence in strokes per minute, counting every blade entry (sprint-kayak convention): divide by 2 for kayak stroke cycles/min, use as-is for canoe. Non-zero values are clamped to 28–165. `0` is ambiguous and means any of: the sport profile is off (profile 0, the default), the device is not currently paddling, or the IMU is unavailable — the three are indistinguishable. The field is absent entirely on firmware that still sends 24-byte GPS records; presence is detected from the record size on the wire, never from `fw_version` (that value is `(major << 8) | minor`, the patch component is dropped, and it was not bumped when cadence was added). The sport profile that enables the detector cannot currently be set through /v1 — POST /v1/devices/{iccid}/commands rejects `cmd: \"config\"`."}}},{"type":"object","additionalProperties":true,"required":["timestamp","battery_v","rssi_dbm","rsrp_dbm","rsrq_db","mcc","mnc","cell_id","tac","earfcn"],"properties":{"timestamp":{"type":"string","format":"date-time"},"battery_v":{"type":"number"},"rssi_dbm":{"type":"number"},"rsrp_dbm":{"type":"number"},"rsrq_db":{"type":"number"},"mcc":{"type":"integer"},"mnc":{"type":"integer"},"cell_id":{"type":"integer"},"tac":{"type":"integer"},"earfcn":{"type":"integer"},"fix_type":{"type":"integer"},"satellites":{"type":"integer"},"fw_version":{"type":"string","description":"`major.minor` only — the wire field is `(major << 8) | minor` and the patch component never leaves the device, so this must not be used for feature detection."},"mode":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]}}},"is_moving":{"type":"boolean"},"temp_c":{"type":"number","description":"Ambient temperature in °C; absent when the device reported no reading."},"operator":{"type":"string"},"rat":{"type":"string"}}},{}]}}}}}]}}]},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/_modes":{"get":{"summary":"List all available mode presets","tags":["meta"],"security":[{"apiKey":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]},"description":{"type":"string"},"gps_send_interval_ms":{"type":"integer"},"status_interval_ms":{"type":"integer"},"imu_enabled":{"type":"boolean"},"unknown":{"type":"boolean","description":"Present (and `true`) only when the device reported a mode id this backend has no preset for; in that case `id` is the only other field."}}}},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/_sports":{"get":{"summary":"List all sport profiles","tags":["meta"],"description":"The four persisted sport profiles accepted by PUT /v1/devices/{iccid}/sport. `0 None` is the device default and keeps paddling cadence off; 1–3 enable it.","security":[{"apiKey":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer","description":"0 None (cadence off, device default) · 1 Canoe · 2 Kayak · 3 Paddle (auto)"},"name":{"type":"string"},"label":{"type":"string"},"description":{"type":"string"},"cadence_enabled":{"type":"boolean","description":"false only for id 0."}}}},"meta":{"type":"object","additionalProperties":true}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}/commands":{"post":{"summary":"Send a command to the device","tags":["commands"],"description":"Publishes the command to MQTT on the appropriate downlink topic. Mode changes are NOT accepted here — use PUT /v1/devices/:iccid/mode (requires write:mode scope).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["cmd"],"properties":{"cmd":{"type":"string","description":"Command name. Unknown names are rejected with 400. `config` is rejected here as well — the two persisted device settings have dedicated endpoints: PUT /v1/devices/{iccid}/mode for the mode preset and PUT /v1/devices/{iccid}/sport for the sport profile (the switch that enables paddling cadence)."},"params":{"type":"object","additionalProperties":true}}}}}},"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["write:commands"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"msg_id":{"type":"string"},"cmd":{"type":"string"},"topic":{"type":"string"}}},"meta":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"404":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/_all/commands":{"post":{"summary":"Send a command to ALL your devices","tags":["commands"],"description":"Fans the command out to every device the key may control: devices you own or that are shared with you with `control`/`manage`, further narrowed by the key’s device whitelist if it has one. Each device receives its own `msg_id`; the audit rows share one `broadcast_id`. Same validation as the per-device endpoint — `config` is rejected here, use PUT /v1/devices/_all/mode.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["cmd"],"properties":{"cmd":{"type":"string","description":"Command name. Unknown names are rejected with 400. `config` is rejected here as well — the two persisted device settings have dedicated endpoints: PUT /v1/devices/{iccid}/mode for the mode preset and PUT /v1/devices/{iccid}/sport for the sport profile (the switch that enables paddling cadence)."},"params":{"type":"object","additionalProperties":true}}}}}},"security":[{"apiKey":["write:commands"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"broadcast_id":{"type":"string"},"cmd":{"type":"string"},"total":{"type":"integer"},"sent":{"type":"array","items":{"type":"object","properties":{"iccid":{"type":"string"},"msg_id":{"type":"string"}}}}}},"meta":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/_all/mode":{"put":{"summary":"Change the mode preset on ALL your devices","tags":["mode"],"description":"Publishes `{cmd:\"config\", mode:<id>}` to every device the key may control (owned, or shared with `control`/`manage`, narrowed by the key’s device whitelist). Each device receives its own `msg_id`; the audit rows share one `broadcast_id`. Device-side switch is async — the next status packet from each device confirms.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["mode_id"],"properties":{"mode_id":{"type":"integer","description":"One of the IDs from GET /v1/devices/_modes."}}}}}},"security":[{"apiKey":["write:mode"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"broadcast_id":{"type":"string"},"requested_mode":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","enum":["standard","logging"]},"description":{"type":"string"},"gps_send_interval_ms":{"type":"integer"},"status_interval_ms":{"type":"integer"},"imu_enabled":{"type":"boolean"},"unknown":{"type":"boolean","description":"Present (and `true`) only when the device reported a mode id this backend has no preset for; in that case `id` is the only other field."}}},"total":{"type":"integer"},"sent":{"type":"array","items":{"type":"object","properties":{"iccid":{"type":"string"},"msg_id":{"type":"string"}}}},"note":{"type":"string"}}},"meta":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}/sport":{"put":{"summary":"Set the device’s sport profile (paddling cadence on/off)","tags":["mode"],"description":"Publishes `{cmd:\"config\", sport:<id>}` to `c/{iccid}/config`. The profile is persisted in device NVS and gates the on-device stroke-cadence detector: `0 None` (default) keeps it off, `1 Canoe` / `2 Kayak` / `3 Paddle (auto)` turn it on, after which `cadence_spm` rides on every GPS/MARK record. Orthogonal to the mode preset. **Write-only** — there is no endpoint that reads the active profile back.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sport_id"],"properties":{"sport_id":{"type":"integer","description":"One of the IDs from GET /v1/devices/_sports: 0 None (cadence off), 1 Canoe, 2 Kayak, 3 Paddle (auto)."}}}}}},"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["write:mode"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"msg_id":{"type":"string"},"requested_sport":{"type":"object","properties":{"id":{"type":"integer","description":"0 None (cadence off, device default) · 1 Canoe · 2 Kayak · 3 Paddle (auto)"},"name":{"type":"string"},"label":{"type":"string"},"description":{"type":"string"},"cadence_enabled":{"type":"boolean","description":"false only for id 0."}}},"note":{"type":"string"}}},"meta":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/_all/sport":{"put":{"summary":"Set the sport profile on ALL your devices","tags":["mode"],"description":"Publishes `{cmd:\"config\", sport:<id>}` to every device the key may control (owned, or shared with `control`/`manage`, narrowed by the key’s device whitelist). Each device receives its own `msg_id`; the audit rows share one `broadcast_id`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["sport_id"],"properties":{"sport_id":{"type":"integer","description":"One of the IDs from GET /v1/devices/_sports: 0 None (cadence off), 1 Canoe, 2 Kayak, 3 Paddle (auto)."}}}}}},"security":[{"apiKey":["write:mode"]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","required":["success","data"],"properties":{"success":{"enum":[true]},"data":{"type":"object","properties":{"broadcast_id":{"type":"string"},"requested_sport":{"type":"object","properties":{"id":{"type":"integer","description":"0 None (cadence off, device default) · 1 Canoe · 2 Kayak · 3 Paddle (auto)"},"name":{"type":"string"},"label":{"type":"string"},"description":{"type":"string"},"cadence_enabled":{"type":"boolean","description":"false only for id 0."}}},"total":{"type":"integer"},"sent":{"type":"array","items":{"type":"object","properties":{"iccid":{"type":"string"},"msg_id":{"type":"string"}}}},"note":{"type":"string"}}},"meta":{"type":"object","additionalProperties":true}}}}}},"400":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Default Response","content":{"application/json":{"schema":{"anyOf":[{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","enum":["unauthorized","forbidden","not_found","bad_request","rate_limited","mode_disabled","internal_error"],"description":"Machine-readable code (ApiErrorCode in envelope.ts)."},"message":{"type":"string"},"details":{}}}}},{"type":"object","required":["success","error"],"properties":{"success":{"enum":[false]},"error":{"type":"string"}}},{"type":"object","required":["statusCode","message"],"properties":{"statusCode":{"type":"integer"},"code":{"type":"string","description":"Fastify error code, e.g. `FST_ERR_VALIDATION`."},"error":{"type":"string","description":"HTTP status text, e.g. `Bad Request`."},"message":{"type":"string"}}},{"type":"object","additionalProperties":true}]}}}},"429":{"$ref":"#/components/responses/TooManyRequests"}}}},"/v1/devices/{iccid}/stream":{"get":{"summary":"Server-Sent Events stream for a single device","tags":["streaming"],"description":"Long-lived `text/event-stream` connection. Events:\n- `event: ready` — `{iccid, format}` on connect\n- `event: packet` — one PacketView per arriving MQTT packet\n- `event: heartbeat` — every 30s\n\n**Required scope:** `read:stream`","parameters":[{"schema":{"type":"string","enum":["raw","formatted"],"default":"formatted"},"in":"query","name":"format","required":false},{"schema":{"type":"string"},"in":"query","name":"topics","required":false,"description":"Comma-separated topic filter, e.g. \"bin,response\"."},{"schema":{"type":"string","minLength":1,"maxLength":20},"in":"path","name":"iccid","required":true}],"security":[{"apiKey":["read:stream"]}],"responses":{"200":{"description":"text/event-stream payload","content":{"application/json":{"schema":{"type":"string","description":"text/event-stream payload"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/TooManyRequests"}}}}},"servers":[{"url":"https://api.motionapi.pro","description":"Production"},{"url":"http://localhost:5173","description":"Local dev"}],"tags":[{"name":"devices","description":"List & inspect devices the caller can access."},{"name":"snapshots","description":"Last packet received on each topic, served from an in-memory cache (no time-series query)."},{"name":"streaming","description":"Real-time packet relay (WebSocket / SSE)."},{"name":"commands","description":"Send device commands via MQTT. `cmd: \"config\"` is rejected here — the persisted settings have dedicated endpoints: PUT /v1/devices/{iccid}/mode (mode preset) and PUT /v1/devices/{iccid}/sport (sport profile / paddling cadence)."},{"name":"mode","description":"Read & change device mode presets; set the sport profile (paddling cadence)."},{"name":"meta","description":"Static reference data (mode preset catalog)."}],"externalDocs":{"url":"https://github.com/MotionApi/docs-tmp","description":"Developer guide, worked examples, and binary-protocol.md — the MA frame layout needed to decode format=raw, including the trailing cadence_spm byte."}}