openapi: 3.1.0
info:
  title: Asgard Mobile API
  version: 1.0.0-draft
  description: |
    Draft REST surface for the Asgard fleet-tracking mobile app, **Release 1 — Core tracking**.
    Scope covered (from the client's updated phasing):

      1. Secure login and permissions
      2. Vehicle list, search, group visibility and live status
      3. Live fleet map and vehicle location
      4. Vehicle details: speed, ignition, last update, mileage and available sensors
      5. Navigate to a vehicle in Google/Apple Maps
      6. Route history and track replay
      7. Trips, stops, parking and journey details
      8. Basic geofence presence, display, visits and dwell time
      9. Historical trip data access (up to 24 months)

    Conventions
      * All timestamps are RFC 3339 UTC (`2026-09-29T08:15:00Z`).
      * All distances are metres, speeds km/h, durations seconds, headings degrees (0-359).
      * Every list endpoint is paginated with `cursor` + `limit` and returns `meta.nextCursor`.
      * Visibility is enforced server-side: a vehicle/group/geofence the caller cannot see returns 404, not 403.
      * The API is versioned in the path (`/api/v1`). Breaking changes go to `/api/v2`.
      * Deep-link URLs for Google/Apple Maps are returned by the server so the app never has to build them.

servers:
  - url: https://api.asgard.example/api/v1
    description: Production
  - url: https://staging-api.asgard.example/api/v1
    description: Staging

tags:
  - name: Auth
  - name: Vehicles
  - name: Live
  - name: History
  - name: Trips
  - name: Geofences
  - name: Groups
  - name: System

security:
  - bearerAuth: []

# ---------------------------------------------------------------------------
paths:
  # ------------------------------------------------------------ Auth
  /auth/login:
    post:
      tags: [Auth]
      security: []
      summary: Log in with Asgard credentials
      operationId: login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username, password]
              properties:
                username: { type: string, example: reports }
                password: { type: string, format: password }
                device:
                  $ref: '#/components/schemas/DeviceInfo'
      responses:
        '200':
          description: Tokens issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenPair' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '423':
          description: Account locked / too many attempts
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /auth/refresh:
    post:
      tags: [Auth]
      security: []
      summary: Exchange a refresh token for a new access token
      operationId: refreshToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refreshToken]
              properties:
                refreshToken: { type: string }
      responses:
        '200':
          description: New token pair (refresh token is rotated)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TokenPair' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /auth/logout:
    post:
      tags: [Auth]
      summary: Revoke the current refresh token (and push registration)
      operationId: logout
      responses:
        '204': { description: Logged out }

  /auth/me:
    get:
      tags: [Auth]
      summary: Current user, role, permissions and visible groups
      operationId: getMe
      responses:
        '200':
          description: The authenticated principal
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
              example:
                id: "u_1042"
                username: reports
                displayName: Reports User
                email: reports@example.com
                account: { id: "acc_7", name: "Vanguarder Demo" }
                role: fleet_manager
                permissions: [vehicles.read, history.read, geofences.read, share.create]
                visibleGroupIds: ["g_1", "g_4"]
                timezone: Europe/London
                units: { distance: km, speed: kmh }

  # ------------------------------------------------------------ Groups
  /groups:
    get:
      tags: [Groups]
      summary: Groups the caller can see (for filtering the vehicle list / map)
      operationId: listGroups
      responses:
        '200':
          description: Group list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Group' }

  # ------------------------------------------------------------ Vehicles
  /vehicles:
    get:
      tags: [Vehicles]
      summary: Searchable, filterable vehicle list with live status
      description: |
        Returns only vehicles in groups the caller may see. Live status is embedded so the
        list screen needs a single call. Use `/live` for map refreshes.
      operationId: listVehicles
      parameters:
        - $ref: '#/components/parameters/q'
        - name: groupId
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: false
          description: Comma-separated group IDs
        - name: status
          in: query
          schema:
            type: array
            items: { $ref: '#/components/schemas/MotionState' }
          style: form
          explode: false
        - name: ignition
          in: query
          schema: { type: boolean }
        - name: sort
          in: query
          schema:
            type: string
            enum: [name, -name, lastUpdate, -lastUpdate, status]
            default: name
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Paginated vehicles
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/VehicleSummary' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
              example:
                data:
                  - id: "v_501"
                    name: "KX19 ABC"
                    registration: "KX19 ABC"
                    vehicleType: { id: "vt_2", name: "Rigid 18t" }
                    groups: [{ id: "g_1", name: "North Depot" }]
                    driver: { id: "d_9", name: "A. Smith" }
                    live:
                      state: moving
                      ignition: true
                      speed: 54
                      heading: 182
                      position: { lat: 53.4808, lon: -2.2426 }
                      address: "M60, Stockport"
                      geofences: [{ id: "gf_3", name: "Stockport Depot" }]
                      lastUpdate: "2026-09-29T08:14:52Z"
                      stale: false
                meta: { nextCursor: "eyJpZCI6IjUwMSJ9", count: 1, total: 312 }

  /vehicles/{vehicleId}:
    get:
      tags: [Vehicles]
      summary: Vehicle detail (static + live + sensors + odometer)
      operationId: getVehicle
      parameters: [ { $ref: '#/components/parameters/vehicleId' } ]
      responses:
        '200':
          description: Vehicle detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VehicleDetail' }
        '404': { $ref: '#/components/responses/NotFound' }

  /vehicles/{vehicleId}/status:
    get:
      tags: [Vehicles]
      summary: Latest position, telemetry and sensor snapshot for one vehicle
      description: Lightweight endpoint the detail screen polls every few seconds.
      operationId: getVehicleStatus
      parameters: [ { $ref: '#/components/parameters/vehicleId' } ]
      responses:
        '200':
          description: Live snapshot
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VehicleStatus' }
        '404': { $ref: '#/components/responses/NotFound' }

  /vehicles/{vehicleId}/sensors:
    get:
      tags: [Vehicles]
      summary: Available sensors and their latest values
      operationId: getVehicleSensors
      parameters: [ { $ref: '#/components/parameters/vehicleId' } ]
      responses:
        '200':
          description: Sensor list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SensorValue' }
              example:
                data:
                  - key: fuel_level
                    label: Fuel level
                    value: 63.5
                    unit: "%"
                    updatedAt: "2026-09-29T08:14:52Z"
                  - key: tpms.axle1.left.pressure
                    label: "TPMS A1 L pressure"
                    value: 8.7
                    unit: bar
                    updatedAt: "2026-09-29T08:14:40Z"

  /vehicles/{vehicleId}/navigation:
    get:
      tags: [Vehicles]
      summary: Deep links to navigate to the vehicle's current position
      operationId: getVehicleNavigationLinks
      parameters: [ { $ref: '#/components/parameters/vehicleId' } ]
      responses:
        '200':
          description: Provider deep links built from the latest position
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NavigationLinks' }
              example:
                position: { lat: 53.4808, lon: -2.2426 }
                asOf: "2026-09-29T08:14:52Z"
                googleMaps: "https://www.google.com/maps/dir/?api=1&destination=53.4808,-2.2426"
                appleMaps: "https://maps.apple.com/?daddr=53.4808,-2.2426"
                waze: "https://waze.com/ul?ll=53.4808,-2.2426&navigate=yes"

  # ------------------------------------------------------------ Live map
  /live:
    get:
      tags: [Live]
      summary: Bulk live positions for the fleet map
      description: |
        Returns the latest position of every visible vehicle (optionally filtered).
        Pass `since` (the `serverTime` of the previous response) to get only vehicles that
        have moved/updated since then — the app merges the delta into its marker set.
        For fleets of thousands of units the app should pass `bbox` and `zoom` so the
        server can return clustered markers instead of raw points.
      operationId: getLivePositions
      parameters:
        - name: groupId
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: false
        - name: status
          in: query
          schema: { type: array, items: { $ref: '#/components/schemas/MotionState' } }
          style: form
          explode: false
        - name: bbox
          in: query
          description: "minLon,minLat,maxLon,maxLat"
          schema: { type: string, example: "-2.5,53.3,-2.0,53.6" }
        - name: zoom
          in: query
          schema: { type: integer, minimum: 0, maximum: 22 }
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: cluster
          in: query
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Live positions (or clusters)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LiveResponse' }
              example:
                serverTime: "2026-09-29T08:15:00Z"
                vehicles:
                  - id: "v_501"
                    name: "KX19 ABC"
                    state: moving
                    ignition: true
                    speed: 54
                    heading: 182
                    position: { lat: 53.4808, lon: -2.2426 }
                    lastUpdate: "2026-09-29T08:14:52Z"
                clusters: []
                removed: []

  /live/stream:
    get:
      tags: [Live]
      summary: Server-Sent Events stream of live position updates (optional, phase 1b)
      description: |
        `text/event-stream`. Each event is one `LiveVehicle` object. Filters mirror `/live`.
        Falls back to polling `/live?since=` when the connection drops.
      operationId: streamLivePositions
      parameters:
        - name: groupId
          in: query
          schema: { type: array, items: { type: string } }
          style: form
          explode: false
      responses:
        '200':
          description: SSE stream
          content:
            text/event-stream:
              schema: { type: string }

  # ------------------------------------------------------------ History / track
  /vehicles/{vehicleId}/track:
    get:
      tags: [History]
      summary: Route history (positions) for track replay
      description: |
        Ordered positions between `from` and `to` (max 7 days per call; page with cursor).
        `simplify` applies Douglas-Peucker with the given tolerance in metres so the map
        draws a clean polyline; omit for full-fidelity replay. Data available for 24 months.
      operationId: getVehicleTrack
      parameters:
        - $ref: '#/components/parameters/vehicleId'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: simplify
          in: query
          description: Tolerance in metres (0 = none)
          schema: { type: number, default: 0 }
        - name: format
          in: query
          schema: { type: string, enum: [json, geojson, polyline], default: json }
        - $ref: '#/components/parameters/cursor'
        - name: limit
          in: query
          schema: { type: integer, default: 5000, maximum: 20000 }
      responses:
        '200':
          description: Track points
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrackResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /vehicles/{vehicleId}/timeline:
    get:
      tags: [History]
      summary: Chronological journey timeline (trips + stops + geofence visits merged)
      description: One call for the "History" screen's day view.
      operationId: getVehicleTimeline
      parameters:
        - $ref: '#/components/parameters/vehicleId'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200':
          description: Timeline
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { $ref: '#/components/schemas/PeriodSummary' }
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/TimelineItem' }

  # ------------------------------------------------------------ Trips / stops
  /vehicles/{vehicleId}/trips:
    get:
      tags: [Trips]
      summary: Trips for a vehicle in a period
      operationId: listVehicleTrips
      parameters:
        - $ref: '#/components/parameters/vehicleId'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: minDistance
          in: query
          description: Drop trips shorter than this many metres
          schema: { type: integer, default: 0 }
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Trips
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary: { $ref: '#/components/schemas/PeriodSummary' }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Trip' }
                  meta: { $ref: '#/components/schemas/PageMeta' }

  /vehicles/{vehicleId}/stops:
    get:
      tags: [Trips]
      summary: Stops and parking events for a vehicle in a period
      description: |
        `type=stop` is ignition-on stationary (idling); `type=parking` is ignition-off.
        Both include duration and the geofence the vehicle was in, if any.
      operationId: listVehicleStops
      parameters:
        - $ref: '#/components/parameters/vehicleId'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: type
          in: query
          schema: { type: string, enum: [stop, parking, all], default: all }
        - name: minDuration
          in: query
          description: Seconds
          schema: { type: integer, default: 0 }
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Stops
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Stop' }
                  meta: { $ref: '#/components/schemas/PageMeta' }

  /trips/{tripId}:
    get:
      tags: [Trips]
      summary: Journey detail for one trip, including its track
      operationId: getTrip
      parameters:
        - name: tripId
          in: path
          required: true
          schema: { type: string }
        - name: includeTrack
          in: query
          schema: { type: boolean, default: true }
      responses:
        '200':
          description: Trip with track and events
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TripDetail' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ------------------------------------------------------------ Geofences
  /geofences:
    get:
      tags: [Geofences]
      summary: Geofences visible to the caller (for map overlay)
      operationId: listGeofences
      parameters:
        - $ref: '#/components/parameters/q'
        - name: bbox
          in: query
          schema: { type: string }
        - name: includeGeometry
          in: query
          schema: { type: boolean, default: true }
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Geofences
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Geofence' }
                  meta: { $ref: '#/components/schemas/PageMeta' }

  /geofences/{geofenceId}:
    get:
      tags: [Geofences]
      summary: Geofence detail with geometry and vehicles currently inside
      operationId: getGeofence
      parameters: [ { $ref: '#/components/parameters/geofenceId' } ]
      responses:
        '200':
          description: Geofence
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Geofence'
                  - type: object
                    properties:
                      vehiclesInside:
                        type: array
                        items: { $ref: '#/components/schemas/VehicleRef' }
        '404': { $ref: '#/components/responses/NotFound' }

  /geofences/{geofenceId}/visits:
    get:
      tags: [Geofences]
      summary: Visits to a geofence (entry, exit, dwell) by any visible vehicle
      operationId: listGeofenceVisits
      parameters:
        - $ref: '#/components/parameters/geofenceId'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: vehicleId
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Visits
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/GeofenceVisit' }
                  meta: { $ref: '#/components/schemas/PageMeta' }

  /vehicles/{vehicleId}/geofences:
    get:
      tags: [Geofences]
      summary: Geofences the vehicle is currently inside (presence)
      operationId: getVehicleGeofencePresence
      parameters: [ { $ref: '#/components/parameters/vehicleId' } ]
      responses:
        '200':
          description: Presence
          content:
            application/json:
              schema:
                type: object
                properties:
                  asOf: { type: string, format: date-time }
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/GeofenceRef'
                        - type: object
                          properties:
                            enteredAt: { type: string, format: date-time }
                            dwellSeconds: { type: integer }

  /vehicles/{vehicleId}/geofence-visits:
    get:
      tags: [Geofences]
      summary: Geofence visits for a vehicle over a period (dwell report)
      operationId: listVehicleGeofenceVisits
      parameters:
        - $ref: '#/components/parameters/vehicleId'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: geofenceId
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Visits
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/GeofenceVisit' }
                  meta: { $ref: '#/components/schemas/PageMeta' }

  # ------------------------------------------------------------ System
  /health:
    get:
      tags: [System]
      security: []
      summary: Liveness / readiness (includes ingest lag)
      operationId: health
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [ok, degraded] }
                  ingestLagSeconds: { type: integer }
                  version: { type: string }

  /config:
    get:
      tags: [System]
      summary: Client bootstrap config (feature flags, min app version, poll intervals)
      operationId: getClientConfig
      responses:
        '200':
          description: Config
          content:
            application/json:
              schema:
                type: object
                properties:
                  minAppVersion: { type: object, properties: { ios: { type: string }, android: { type: string } } }
                  features:
                    type: object
                    additionalProperties: { type: boolean }
                    example: { liveStream: false, geofences: true, notifications: false }
                  livePollIntervalSeconds: { type: integer, example: 10 }
                  staleAfterSeconds: { type: integer, example: 600 }
                  historyRetentionMonths: { type: integer, example: 24 }

# ---------------------------------------------------------------------------
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    vehicleId:
      name: vehicleId
      in: path
      required: true
      schema: { type: string }
    geofenceId:
      name: geofenceId
      in: path
      required: true
      schema: { type: string }
    q:
      name: q
      in: query
      description: Free-text search (name, registration, driver, VIN)
      schema: { type: string }
    from:
      name: from
      in: query
      required: true
      schema: { type: string, format: date-time }
    to:
      name: to
      in: query
      required: true
      schema: { type: string, format: date-time }
    cursor:
      name: cursor
      in: query
      schema: { type: string }
    limit:
      name: limit
      in: query
      schema: { type: integer, default: 50, minimum: 1, maximum: 500 }

  responses:
    Unauthorized:
      description: Missing / invalid / expired token
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: Not found or not visible to caller
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    BadRequest:
      description: Validation error
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code: { type: string, example: validation_error }
        message: { type: string }
        details: { type: object, additionalProperties: true }

    PageMeta:
      type: object
      properties:
        nextCursor: { type: [string, 'null'] }
        count: { type: integer }
        total: { type: integer, description: Omitted when expensive to compute }

    DeviceInfo:
      type: object
      properties:
        platform: { type: string, enum: [ios, android] }
        appVersion: { type: string }
        deviceId: { type: string }
        pushToken: { type: string, description: Reserved for Release 2 notifications }

    TokenPair:
      type: object
      required: [accessToken, refreshToken, expiresIn]
      properties:
        accessToken: { type: string }
        refreshToken: { type: string }
        tokenType: { type: string, default: Bearer }
        expiresIn: { type: integer, description: Access token lifetime in seconds, example: 900 }

    Me:
      type: object
      properties:
        id: { type: string }
        username: { type: string }
        displayName: { type: string }
        email: { type: string }
        account:
          type: object
          properties: { id: { type: string }, name: { type: string } }
        role: { type: string, enum: [admin, fleet_manager, dispatcher, viewer] }
        permissions:
          type: array
          items: { type: string }
        visibleGroupIds:
          type: array
          items: { type: string }
        timezone: { type: string }
        units:
          type: object
          properties:
            distance: { type: string, enum: [km, mi] }
            speed: { type: string, enum: [kmh, mph] }

    Group:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        vehicleCount: { type: integer }
        parentId: { type: [string, 'null'] }

    VehicleRef:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        registration: { type: string }

    GeofenceRef:
      type: object
      properties:
        id: { type: string }
        name: { type: string }

    LatLng:
      type: object
      required: [lat, lon]
      properties:
        lat: { type: number, format: double }
        lon: { type: number, format: double }

    MotionState:
      type: string
      enum: [moving, stopped, parked, offline]
      description: |
        moving  = speed > threshold and message is fresh
        stopped = ignition on, speed 0 (idling)
        parked  = ignition off
        offline = no message within `staleAfterSeconds`

    LiveStatus:
      type: object
      properties:
        state: { $ref: '#/components/schemas/MotionState' }
        ignition: { type: [boolean, 'null'] }
        speed: { type: number, description: km/h }
        heading: { type: integer }
        position: { $ref: '#/components/schemas/LatLng' }
        address: { type: [string, 'null'] }
        geofences:
          type: array
          items: { $ref: '#/components/schemas/GeofenceRef' }
        lastUpdate: { type: string, format: date-time }
        stale: { type: boolean }

    VehicleSummary:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        registration: { type: string }
        vehicleType:
          type: object
          properties: { id: { type: string }, name: { type: string } }
        groups:
          type: array
          items: { $ref: '#/components/schemas/Group' }
        driver:
          type: [object, 'null']
          properties: { id: { type: string }, name: { type: string }, phone: { type: string } }
        live: { $ref: '#/components/schemas/LiveStatus' }

    VehicleDetail:
      allOf:
        - $ref: '#/components/schemas/VehicleSummary'
        - type: object
          properties:
            vin: { type: string }
            make: { type: string }
            model: { type: string }
            imei: { type: string }
            deviceModel: { type: string }
            odometer:
              type: object
              properties:
                total: { type: number, description: metres }
                today: { type: number, description: metres since local midnight }
                source: { type: string, enum: [can, gps, manual] }
            engineHours: { type: [number, 'null'] }
            currentTrip:
              type: [object, 'null']
              properties:
                startedAt: { type: string, format: date-time }
                durationSeconds: { type: integer }
                distance: { type: number }
                startAddress: { type: string }
            sensors:
              type: array
              items: { $ref: '#/components/schemas/SensorValue' }
            navigation: { $ref: '#/components/schemas/NavigationLinks' }

    VehicleStatus:
      allOf:
        - $ref: '#/components/schemas/LiveStatus'
        - type: object
          properties:
            vehicleId: { type: string }
            odometer: { type: number }
            fuelLevel: { type: [number, 'null'] }
            batteryVoltage: { type: [number, 'null'] }
            gsmSignal: { type: [integer, 'null'] }
            satellites: { type: [integer, 'null'] }
            sensors:
              type: array
              items: { $ref: '#/components/schemas/SensorValue' }

    SensorValue:
      type: object
      properties:
        key: { type: string, description: "Stable machine key, e.g. fuel_level" }
        label: { type: string }
        value:
          oneOf:
            - { type: number }
            - { type: string }
            - { type: boolean }
            - { type: 'null' }
        unit: { type: [string, 'null'] }
        category: { type: string, enum: [fuel, tpms, temperature, brake, weight, io, other] }
        updatedAt: { type: string, format: date-time }

    NavigationLinks:
      type: object
      properties:
        position: { $ref: '#/components/schemas/LatLng' }
        asOf: { type: string, format: date-time }
        googleMaps: { type: string, format: uri }
        appleMaps: { type: string, format: uri }
        waze: { type: string, format: uri }

    LiveVehicle:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        state: { $ref: '#/components/schemas/MotionState' }
        ignition: { type: [boolean, 'null'] }
        speed: { type: number }
        heading: { type: integer }
        position: { $ref: '#/components/schemas/LatLng' }
        lastUpdate: { type: string, format: date-time }

    Cluster:
      type: object
      properties:
        position: { $ref: '#/components/schemas/LatLng' }
        count: { type: integer }
        bbox: { type: string }
        stateCounts:
          type: object
          additionalProperties: { type: integer }

    LiveResponse:
      type: object
      properties:
        serverTime: { type: string, format: date-time }
        vehicles:
          type: array
          items: { $ref: '#/components/schemas/LiveVehicle' }
        clusters:
          type: array
          items: { $ref: '#/components/schemas/Cluster' }
        removed:
          type: array
          description: Vehicle IDs no longer visible (delta responses only)
          items: { type: string }

    TrackPoint:
      type: object
      required: [t, lat, lon]
      properties:
        t: { type: string, format: date-time }
        lat: { type: number }
        lon: { type: number }
        speed: { type: number }
        heading: { type: integer }
        ignition: { type: [boolean, 'null'] }
        odometer: { type: [number, 'null'] }
        altitude: { type: [number, 'null'] }

    TrackResponse:
      type: object
      properties:
        vehicleId: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        pointCount: { type: integer }
        distance: { type: number }
        bbox: { type: string }
        points:
          type: array
          items: { $ref: '#/components/schemas/TrackPoint' }
        meta: { $ref: '#/components/schemas/PageMeta' }

    PeriodSummary:
      type: object
      properties:
        tripCount: { type: integer }
        distance: { type: number }
        drivingSeconds: { type: integer }
        stoppedSeconds: { type: integer }
        parkedSeconds: { type: integer }
        maxSpeed: { type: number }
        avgSpeed: { type: number }

    TripEndpoint:
      type: object
      properties:
        time: { type: string, format: date-time }
        position: { $ref: '#/components/schemas/LatLng' }
        address: { type: [string, 'null'] }
        geofence: { oneOf: [ { $ref: '#/components/schemas/GeofenceRef' }, { type: 'null' } ] }
        odometer: { type: [number, 'null'] }

    Trip:
      type: object
      properties:
        id: { type: string }
        vehicleId: { type: string }
        driver:
          type: [object, 'null']
          properties: { id: { type: string }, name: { type: string } }
        start: { $ref: '#/components/schemas/TripEndpoint' }
        end: { $ref: '#/components/schemas/TripEndpoint' }
        durationSeconds: { type: integer }
        distance: { type: number }
        maxSpeed: { type: number }
        avgSpeed: { type: number }
        idleSeconds: { type: integer }
        inProgress: { type: boolean }

    TripDetail:
      allOf:
        - $ref: '#/components/schemas/Trip'
        - type: object
          properties:
            track:
              type: array
              items: { $ref: '#/components/schemas/TrackPoint' }
            stops:
              type: array
              items: { $ref: '#/components/schemas/Stop' }
            geofenceVisits:
              type: array
              items: { $ref: '#/components/schemas/GeofenceVisit' }
            fuelUsed: { type: [number, 'null'] }

    Stop:
      type: object
      properties:
        id: { type: string }
        vehicleId: { type: string }
        type: { type: string, enum: [stop, parking] }
        start: { type: string, format: date-time }
        end: { type: [string, 'null'], format: date-time }
        durationSeconds: { type: integer }
        position: { $ref: '#/components/schemas/LatLng' }
        address: { type: [string, 'null'] }
        geofence: { oneOf: [ { $ref: '#/components/schemas/GeofenceRef' }, { type: 'null' } ] }
        inProgress: { type: boolean }

    TimelineItem:
      type: object
      properties:
        type: { type: string, enum: [trip, stop, parking, geofence_visit] }
        start: { type: string, format: date-time }
        end: { type: [string, 'null'], format: date-time }
        durationSeconds: { type: integer }
        trip: { $ref: '#/components/schemas/Trip' }
        stop: { $ref: '#/components/schemas/Stop' }
        geofenceVisit: { $ref: '#/components/schemas/GeofenceVisit' }

    Geofence:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: [string, 'null'] }
        colour: { type: string, example: "#FF8800" }
        shape: { type: string, enum: [polygon, circle] }
        geometry:
          description: GeoJSON geometry (Polygon), or Point+radius for circles
          type: object
          additionalProperties: true
          example:
            type: Polygon
            coordinates: [[[-2.25, 53.48], [-2.24, 53.48], [-2.24, 53.49], [-2.25, 53.49], [-2.25, 53.48]]]
        radius: { type: [number, 'null'], description: "Metres, circles only" }
        centre: { $ref: '#/components/schemas/LatLng' }
        groupIds:
          type: array
          items: { type: string }

    GeofenceVisit:
      type: object
      properties:
        id: { type: string }
        vehicle: { $ref: '#/components/schemas/VehicleRef' }
        geofence: { $ref: '#/components/schemas/GeofenceRef' }
        enteredAt: { type: string, format: date-time }
        exitedAt: { type: [string, 'null'], format: date-time }
        dwellSeconds: { type: integer }
        inProgress: { type: boolean }
