openapi: 3.1.0
info:
  title: UNIVIP v1 REST interface
  version: 0.2.0-draft
  summary: Universal Vehicle Interoperability Protocol — configuration and command plane
  description: |
    REST surface of UNIVIP (spec Part 05 §5.5). All paths are relative to a base of the form
    `/univip/v1/devices/{device_id}`. The same paths are served by a device that hosts its own
    listener and by a client-hosted endpoint that forwards them to the device as `request` envelopes
    over the session channel (Part 05 §5.5.1). Message bodies are defined by the JSON Schemas in `../schemas/`.
    Errors are RFC 9457 problem details whose `type` is one of the URIs registered in Part 05 §5.5.2.
  license:
    name: Apache-2.0
    identifier: Apache-2.0
externalDocs:
  description: UNIVIP specification
  url: https://univip.org/spec/
servers:
  - url: https://{host}/univip/v1
    variables:
      host:
        default: vms.example.net
        description: Client-hosted UNIVIP endpoint, or the device's own listener when `transports.rest.device_hosted` is true
security:
  - bearerAuth: []
  - mutualTLS: []
tags:
  - name: device
  - name: telemetry
  - name: events
  - name: media
  - name: commands
  - name: leases
  - name: config
paths:
  /devices/{device_id}:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [device]
      operationId: getDevice
      summary: Device identity
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      responses:
        '200':
          description: Identity block
          content:
            application/json:
              schema:
                $ref: '../schemas/common.schema.json#/$defs/Identity'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/capabilities:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [device]
      operationId: getCapabilities
      summary: Capability document (Part 06)
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      responses:
        '200':
          description: Capability document
          headers:
            ETag:
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: '../schemas/capabilities.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/telemetry/latest:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [telemetry]
      operationId: getLatestTelemetry
      summary: Most recent telemetry sample
      description: For clients that do not hold a session channel. Real-time consumers use the session channel.
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      responses:
        '200':
          description: Telemetry sample
          content:
            application/json:
              schema:
                $ref: '../schemas/telemetry.schema.json'
        '204':
          description: No sample available yet
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/events:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [events]
      operationId: listEvents
      summary: Recent events
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      parameters:
        - name: topic
          in: query
          description: Exact topic or prefix ending in `.*`
          schema: { type: string }
        - name: since
          in: query
          schema: { type: string, format: date-time }
        - name: until
          in: query
          schema: { type: string, format: date-time }
        - name: severity_min
          in: query
          schema:
            $ref: '../schemas/common.schema.json#/$defs/Severity'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Page of events, oldest first
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events:
                    type: array
                    items:
                      $ref: '../schemas/event.schema.json'
                  next_cursor:
                    type: [string, 'null']
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/media/streams:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [media]
      operationId: listStreams
      summary: Stream descriptors (Part 09 §9.2)
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      responses:
        '200':
          description: Stream descriptors
          content:
            application/json:
              schema:
                type: object
                required: [streams]
                properties:
                  streams:
                    type: array
                    items:
                      $ref: '../schemas/capabilities.schema.json#/$defs/StreamDescriptor'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/media/streams/{stream_id}/webrtc:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
      - name: stream_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [media]
      operationId: negotiateWebrtc
      summary: HTTP WebRTC signaling (WHEP-style alternative to `signal` envelopes)
      description: |
        The client posts an SDP offer; the response carries the device's SDP answer. ICE candidates
        are expected to be included in the SDP (non-trickle) in this mode. Clients needing trickle ICE
        use the session channel instead.
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      requestBody:
        required: true
        content:
          application/sdp:
            schema: { type: string }
      responses:
        '201':
          description: SDP answer
          headers:
            Location:
              description: Resource representing the WebRTC session; DELETE it to tear down
              schema: { type: string }
          content:
            application/sdp:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Stream is at its session limit (`stream-busy`)
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '413':
          description: Offer larger than 64 KiB (`malformed`)
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '415':
          description: Body is not `application/sdp` (`unsupported-media-type`)
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /devices/{device_id}/media/streams/{stream_id}/webrtc/{resource_id}:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
      - name: stream_id
        in: path
        required: true
        schema: { type: string }
      - name: resource_id
        in: path
        required: true
        description: Unguessable identifier from the `Location` header of the negotiation response
        schema: { type: string }
    delete:
      tags: [media]
      operationId: closeWebrtc
      summary: Tear down a WebRTC session negotiated over HTTP
      description: Only the client that created the session (same authenticated `client_id`) may delete it; for anyone else the session does not exist (404).
      security:
        - bearerAuth: [univip.view]
        - mutualTLS: []
      responses:
        '200':
          description: Session closed
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/commands:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    post:
      tags: [commands]
      operationId: submitCommand
      summary: Submit a camera.*, payload.* or vehicle.* command
      description: |
        Returns `ACCEPTED` or `REJECTED` synchronously. `ACCEPTED` means the device will attempt the
        command and never means completion; subsequent states arrive as `command_status` envelopes on the
        session channel and via `GET /commands/{command_id}`. `vehicle.*` requires `univip.vehicle` scope and
        a valid vehicle lease; `camera.*`/`payload.*` require `univip.camera` and, when the payload declares
        `lease_required`, a camera lease.
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '../schemas/command.schema.json'
      responses:
        '202':
          description: Command accepted for execution
          content:
            application/json:
              schema:
                $ref: '../schemas/command_status.schema.json'
        '200':
          description: Command completed synchronously (permitted only for instantaneous camera settings)
          content:
            application/json:
              schema:
                $ref: '../schemas/command_status.schema.json'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            The credential lacks the OAuth scope for this command family (`univip.camera` or `univip.vehicle`).
            Lease problems are not 403: they are rejections (422) with reason `NO_LEASE`, `LEASE_SCOPE` or
            `LEASE_EXPIRED`, so that the client receives a command status.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Duplicate command_id
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: "Command rejected by the device; body carries state REJECTED and a reason"
          content:
            application/json:
              schema:
                $ref: '../schemas/command_status.schema.json'

  /devices/{device_id}/commands/{command_id}:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
      - name: command_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [commands]
      operationId: getCommandStatus
      summary: Current command status
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      responses:
        '200':
          description: Latest status
          content:
            application/json:
              schema:
                $ref: '../schemas/command_status.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [commands]
      operationId: cancelCommand
      summary: Cancel a queued or executing command
      description: For aircraft movement the aircraft transitions to hold. Completed commands return 409. A device that cannot confirm cancellation returns 409 command-not-cancellable and leaves the original command active; this does not guarantee that the vehicle has stopped.
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      responses:
        '202':
          description: Cancellation requested
          content:
            application/json:
              schema:
                $ref: '../schemas/command_status.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Command already in a terminal state, or cancellation cannot be confirmed
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /devices/{device_id}/leases:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [leases]
      operationId: listLeases
      summary: Current leases
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      responses:
        '200':
          description: Leases visible to the caller
          content:
            application/json:
              schema:
                type: object
                required: [leases]
                properties:
                  leases:
                    type: array
                    items:
                      $ref: '../schemas/lease.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      tags: [leases]
      operationId: acquireLease
      summary: Acquire a control lease (Part 11 §11.3)
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '../schemas/lease.schema.json'
      responses:
        '201':
          description: Lease granted (state ACTIVE) or awaiting pilot acknowledgment (state PENDING)
          content:
            application/json:
              schema:
                $ref: '../schemas/lease.schema.json'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Scope not held (`forbidden-scope`), `client_id` not that of the credential (`lease-scope`), profile not offered (`unsupported`), or Profile N disabled by the operator (`flight-control-disabled`)
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '409':
          description: An active lease with this scope is held by another client
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /devices/{device_id}/leases/{lease_id}:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
      - name: lease_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [leases]
      operationId: getLease
      summary: Lease state
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      responses:
        '200':
          description: Lease
          content:
            application/json:
              schema:
                $ref: '../schemas/lease.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [leases]
      operationId: heartbeatOrExtendLease
      summary: Heartbeat or extend a lease
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                heartbeat: { type: boolean }
                extend_s: { type: number, exclusiveMinimum: 0 }
              anyOf:
                - required: [heartbeat]
                - required: [extend_s]
      responses:
        '200':
          description: Updated lease
          content:
            application/json:
              schema:
                $ref: '../schemas/lease.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410':
          description: Lease is no longer ACTIVE
          content:
            application/json:
              schema:
                $ref: '../schemas/lease.schema.json'
    delete:
      tags: [leases]
      operationId: releaseLease
      summary: Release a lease
      security:
        - bearerAuth: [univip.camera]
        - bearerAuth: [univip.vehicle]
        - mutualTLS: []
      responses:
        '200':
          description: Lease released
          content:
            application/json:
              schema:
                $ref: '../schemas/lease.schema.json'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /devices/{device_id}/config:
    parameters:
      - $ref: '#/components/parameters/DeviceId'
    get:
      tags: [config]
      operationId: getConfig
      summary: Client-visible configuration
      security:
        - bearerAuth: [univip.config]
        - mutualTLS: []
      responses:
        '200':
          description: Configuration
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Config' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [config]
      operationId: patchConfig
      summary: Change client-visible configuration
      security:
        - bearerAuth: [univip.config]
        - mutualTLS: []
      requestBody:
        required: true
        content:
          application/merge-patch+json:
            schema: { $ref: '#/components/schemas/Config' }
      responses:
        '200':
          description: Applied configuration (values may be clamped to capabilities)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Config' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: OAuth 2.0 access token with UNIVIP scopes (Part 13 §13.4)
    mutualTLS:
      type: mutualTLS
      description: X.509 client certificate; scopes carried as certificate attributes
  parameters:
    DeviceId:
      name: device_id
      in: path
      required: true
      schema:
        $ref: '../schemas/common.schema.json#/$defs/DeviceId'
  schemas:
    Problem:
      type: object
      description: RFC 9457 Problem Details
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: |
            `https://univip.org/problems/<slug>` with a slug registered in Part 05 §5.5.2: `malformed`,
            `unsupported-extension`, `unsupported-media-type`, `unauthorized`, `forbidden-scope`, `lease-scope`,
            `unsupported`, `flight-control-disabled`, `not-found`, `duplicate-command`, `command-terminal`, `command-not-cancellable`,
            `lease-conflict`, `lease-state`, `stream-busy`, `lease-gone`, `rate-limited`, `internal`.
            Vendor problem types use a vendor URI.
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        univip_code:
          type: string
          description: UNIVIP error or rejection code where applicable
    Config:
      type: object
      properties:
        telemetry_rate_hz:
          type: number
          exclusiveMinimum: 0
        default_stream_id:
          type: string
        event_min_severity:
          $ref: '../schemas/common.schema.json#/$defs/Severity'
  responses:
    BadRequest:
      description: Malformed request (schema validation failed)
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Unknown device or resource
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
