openapi: 3.1.0
info:
  title: ScreenTinker Public API
  version: 1.9.28
  description: |
    Public, token-scoped REST API for ScreenTinker digital signage.

    ## Authentication
    Every endpoint (except the two unauthenticated device-render endpoints) accepts a
    **scoped personal access token** presented as `Authorization: Bearer st_...`. The
    dashboard's session JWT is also accepted, but this contract targets token consumers.

    ## Scope ladder (read &#60; write &#60; full)
    Tokens carry one of three scopes; higher scopes subsume lower ones:
    - `read`  — all `GET` endpoints.
    - `write` — all resource mutations (`POST` / `PUT` / `PATCH` / `DELETE`).
    - `full`  — operational fleet commands (currently only
      `POST /groups/{id}/command`: reboot / shutdown / screen on/off).

    Each operation advertises its minimum scope via the `x-required-scope` extension
    and restates it in the operation description. A token with insufficient scope is
    rejected with `403`.

    All resources are scoped to the token's workspace; cross-workspace access is not
    available to tokens.
servers:
  - url: /api

tags:
  - name: content
    description: Media library items (uploads, remote URLs, YouTube).
  - name: folders
    description: Content folder hierarchy.
  - name: playlists
    description: Playlists and their items / per-item schedules.
  - name: assignments
    description: Device playlist items (per-device assignment helpers).
  - name: devices
    description: Registered display devices.
  - name: layouts
    description: Multi-zone screen layouts and zones.
  - name: groups
    description: Device groups and bulk operations.
  - name: schedules
    description: Time-based content scheduling.
  - name: walls
    description: Video walls (multi-device grids).
  - name: reports
    description: Proof-of-play and uptime reporting.
  - name: widgets
    description: Dynamic widgets (clock, weather, RSS, etc.) and their render output.
  - name: activity
    description: Activity log.
  - name: kiosk
    description: Interactive kiosk pages and their render output.
  - name: pip
    description: Picture-in-picture / overlay push (image or web on top of the running playlist).

security:
  - ApiToken: []
  - SessionJWT: []

components:
  securitySchemes:
    ApiToken:
      type: http
      scheme: bearer
      bearerFormat: "st_<token>"
      description: |
        Scoped personal access token. Send as `Authorization: Bearer st_...`.
        The token's scope (`read`, `write`, or `full`) gates which operations
        it may call (see the scope ladder in the API description).
    SessionJWT:
      type: http
      scheme: bearer
      description: |
        Dashboard session JWT. Also accepted on every authenticated endpoint,
        but this spec targets scoped-token consumers.

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string

    Device:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        status:
          type: string
        workspace_id:
          type: [string, "null"]
        playlist_id:
          type: [string, "null"]
        layout_id:
          type: [string, "null"]
          description: Assigned layout (null = fullscreen). Recently added.
        timezone:
          type: string
        orientation:
          type: string
        notes:
          type: string
        created_at:
          type: integer

        # --- Network addresses -------------------------------------------------------------
        # Two different addresses, easy to confuse, so both are spelled out. They answer
        # different questions and either can be null.
        ip_address:
          type: [string, "null"]
          description: |
            The device's **public (WAN)** address as observed by the server when the player
            connected — i.e. what the internet sees. Behind a reverse proxy this is taken from
            the first `X-Forwarded-For` entry, otherwise the socket peer address. Every device
            on one site normally shares this. Null until the device has connected at least once.
        local_ip:
          type: [string, "null"]
          description: |
            The device's **own address on its local network** (e.g. `192.168.1.42`), as
            reported by the player itself. This is the one to use to reach a panel directly on
            site. Null on players that do not report it, or where the platform withholds it.

        # --- Latest telemetry --------------------------------------------------------------
        # Flattened from the most recent telemetry report. All null for a device that has
        # never reported, and for platforms that cannot supply a given metric — treat every
        # field here as optional rather than assuming a web player reports what Android does.
        wifi_ssid:
          type: [string, "null"]
          description: |
            Wi-Fi network name, or null on a wired/unknown connection.

            Special value `"permission"` means the device is on Wi-Fi but the operating system
            withheld the name — on Android 10+ reading the SSID requires a location permission
            that ScreenTinker only asks for if an operator opts in. Treat `"permission"` as
            "connected, name unavailable", not as a network literally called that.
        wifi_rssi:
          type: [integer, "null"]
          description: Signal strength in dBm (negative; closer to zero is stronger).
        battery_level:
          type: [integer, "null"]
          description: Battery percentage 0-100, or null on mains-powered hardware.
        battery_charging:
          type: [integer, "null"]
          description: 1 charging, 0 not charging, null unknown.
        storage_free_mb:
          type: [integer, "null"]
        storage_total_mb:
          type: [integer, "null"]
        ram_free_mb:
          type: [integer, "null"]
        ram_total_mb:
          type: [integer, "null"]
        cpu_usage:
          type: [number, "null"]
          description: Recent CPU utilisation as a percentage, where the platform exposes it.
        uptime_seconds:
          type: [integer, "null"]
          description: Seconds since the device booted.

    Playlist:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        status:
          type: string
          description: draft | published
        workspace_id:
          type: [string, "null"]
        item_count:
          type: integer
        display_count:
          type: integer

    PlaylistItem:
      type: object
      properties:
        id:
          type: integer
        playlist_id:
          type: string
        content_id:
          type: [string, "null"]
        widget_id:
          type: [string, "null"]
        zone_id:
          type: [string, "null"]
          description: Optional layout-zone placement. Recently added.
        sort_order:
          type: integer
        duration_sec:
          type: integer

    Content:
      type: object
      properties:
        id:
          type: string
        filename:
          type: string
        mime_type:
          type: string
        file_size:
          type: integer
        duration_sec:
          type: [number, "null"]
        remote_url:
          type: [string, "null"]
        thumbnail_path:
          type: [string, "null"]
        folder_id:
          type: [string, "null"]
        workspace_id:
          type: [string, "null"]

    Layout:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        width:
          type: integer
        height:
          type: integer
        is_template:
          type: integer
        workspace_id:
          type: [string, "null"]
        zones:
          type: array
          items:
            $ref: '#/components/schemas/Zone'

    Zone:
      type: object
      properties:
        id:
          type: string
        layout_id:
          type: string
        name:
          type: string
        x_percent:
          type: number
        y_percent:
          type: number
        width_percent:
          type: number
        height_percent:
          type: number
        z_index:
          type: integer
        zone_type:
          type: string
        fit_mode:
          type: string
        background_color:
          type: string

    Widget:
      type: object
      properties:
        id:
          type: string
        widget_type:
          type: string
          description: clock | weather | rss | text | webpage | social | directory-board | directory-search
        name:
          type: string
        config:
          type: object
        workspace_id:
          type: [string, "null"]

    Group:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        color:
          type: string
          description: "#RRGGBB"
        playlist_id:
          type: [string, "null"]
        device_count:
          type: integer
        workspace_id:
          type: [string, "null"]

    Schedule:
      type: object
      properties:
        id:
          type: string
        device_id:
          type: [string, "null"]
        group_id:
          type: [string, "null"]
        zone_id:
          type: [string, "null"]
        content_id:
          type: [string, "null"]
        widget_id:
          type: [string, "null"]
        layout_id:
          type: [string, "null"]
        playlist_id:
          type: [string, "null"]
        title:
          type: string
        start_time:
          type: string
        end_time:
          type: string
        timezone:
          type: string
        recurrence:
          type: [string, "null"]
        priority:
          type: integer
        enabled:
          type: integer

  parameters:
    Limit:
      name: limit
      in: query
      schema:
        type: integer
      description: Max rows to return.
    Offset:
      name: offset
      in: query
      schema:
        type: integer
      description: Row offset for pagination.

paths:
  # ------------------------------------------------------------------ content
  /content:
    get:
      tags: [content]
      summary: List content in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - name: folder
          in: query
          schema: { type: string }
        - name: folder_id
          in: query
          schema: { type: string }
          description: '"root"/"" = root-level only; UUID = that folder.'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Array of content items.
    post:
      tags: [content]
      summary: Upload a content file (multipart)
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '201': { description: Created content item. }
  /content/folders:
    get:
      tags: [content]
      summary: List distinct content folder names with counts
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200':
          description: 'Array of { folder, count }.'
  /content/remote:
    post:
      tags: [content]
      summary: Add a remote-URL content item
      description: 'Requires scope: write. SSRF-gated (http/https, no internal hosts).'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string }
                name: { type: string }
                mime_type: { type: string }
      responses:
        '201': { description: Created content item. }
  /content/youtube:
    post:
      tags: [content]
      summary: Add a YouTube video as content
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string }
                name: { type: string }
      responses:
        '201': { description: Created content item. }
  /content/{id}:
    get:
      tags: [content]
      summary: Get content metadata
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Content item. }
        '404': { description: Not found. }
    put:
      tags: [content]
      summary: Update content metadata
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                filename: { type: string }
                mime_type: { type: string }
                remote_url: { type: string }
                folder: { type: string }
                folder_id: { type: string }
      responses:
        '200': { description: Updated content item. }
    delete:
      tags: [content]
      summary: Delete content
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success, affectedDevices }.' }
  /content/{id}/replace:
    put:
      tags: [content]
      summary: Replace the content file (multipart)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        '200': { description: Updated content item. }
  /content/{id}/file:
    get:
      tags: [content]
      summary: Download the content file
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Binary file stream. }
  /content/{id}/thumbnail:
    get:
      tags: [content]
      summary: Download the content thumbnail
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Binary image stream. }

  # ------------------------------------------------------------------ folders
  /folders:
    get:
      tags: [folders]
      summary: List content folders in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200': { description: Array of folders. }
    post:
      tags: [folders]
      summary: Create a folder
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                parent_id: { type: string }
      responses:
        '201': { description: Created folder. }
  /folders/{id}:
    put:
      tags: [folders]
      summary: Rename or move a folder
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                parent_id: { type: [string, "null"] }
      responses:
        '200': { description: Updated folder. }
    delete:
      tags: [folders]
      summary: Delete a folder
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }

  # ---------------------------------------------------------------- playlists
  /playlists:
    get:
      tags: [playlists]
      summary: List playlists in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200': { description: Array of playlists. }
    post:
      tags: [playlists]
      summary: Create a playlist
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '201': { description: Created playlist. }
  /playlists/{id}:
    get:
      tags: [playlists]
      summary: Get a playlist with its items
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Playlist with items. }
    put:
      tags: [playlists]
      summary: Update playlist name/description
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
      responses:
        '200': { description: Updated playlist. }
    delete:
      tags: [playlists]
      summary: Delete a playlist
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /playlists/{id}/publish:
    post:
      tags: [playlists]
      summary: Publish a playlist (snapshot + push to devices)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Published playlist with items. }
  /playlists/{id}/discard:
    post:
      tags: [playlists]
      summary: Discard draft changes (revert to published snapshot)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Reverted playlist with items. }
  /playlists/{id}/items:
    get:
      tags: [playlists]
      summary: List a playlist's items
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Array of playlist items. }
    post:
      tags: [playlists]
      summary: Add a content or widget item to a playlist
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                content_id: { type: string }
                widget_id: { type: string }
                zone_id: { type: string }
                sort_order: { type: integer }
                duration_sec: { type: integer }
      responses:
        '201': { description: Created playlist item. }
  /playlists/{id}/items/reorder:
    post:
      tags: [playlists]
      summary: Reorder playlist items
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: array
                  items: { type: integer }
      responses:
        '200': { description: Reordered items. }
  /playlists/{id}/items/{itemId}:
    put:
      tags: [playlists]
      summary: Update a playlist item
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: itemId, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                sort_order: { type: integer }
                duration_sec: { type: integer }
                zone_id: { type: [string, "null"] }
      responses:
        '200': { description: Updated item. }
    delete:
      tags: [playlists]
      summary: Delete a playlist item
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: itemId, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: '{ success }.' }
  /playlists/{id}/items/{itemId}/schedules:
    get:
      tags: [playlists]
      summary: Get a playlist item's schedule blocks
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: itemId, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: Array of schedule blocks. }
    put:
      tags: [playlists]
      summary: Replace a playlist item's schedule blocks
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: itemId, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [blocks]
              properties:
                blocks:
                  type: array
                  items:
                    type: object
                    properties:
                      days:
                        type: array
                        items: { type: integer }
                      start: { type: string }
                      end: { type: string }
                      start_date: { type: [string, "null"] }
                      end_date: { type: [string, "null"] }
      responses:
        '200': { description: Stored schedule blocks. }
  /playlists/{id}/assign:
    post:
      tags: [playlists]
      summary: Assign the playlist to a device
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id]
              properties:
                device_id: { type: string }
      responses:
        '200': { description: '{ success }.' }

  # -------------------------------------------------------------- assignments
  /assignments/device/{deviceId}:
    get:
      tags: [assignments]
      summary: List a device's playlist items
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Array of items. }
    post:
      tags: [assignments]
      summary: Add a content/widget item to a device's playlist
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                content_id: { type: string }
                widget_id: { type: string }
                zone_id: { type: string }
                duration_sec: { type: integer }
                sort_order: { type: integer }
      responses:
        '201': { description: Created item. }
  /assignments/device/{deviceId}/reorder:
    post:
      tags: [assignments]
      summary: Reorder a device's playlist items
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order:
                  type: array
                  items: { type: integer }
      responses:
        '200': { description: Reordered items. }
  /assignments/device/{deviceId}/copy-to/{targetDeviceId}:
    post:
      tags: [assignments]
      summary: Copy a device's playlist to another device
      description: 'Requires scope: write. Both devices must share a workspace.'
      x-required-scope: write
      parameters:
        - { name: deviceId, in: path, required: true, schema: { type: string } }
        - { name: targetDeviceId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                replace: { type: boolean }
      responses:
        '200': { description: '{ success, copied }.' }
  /assignments/{id}:
    put:
      tags: [assignments]
      summary: Update a playlist item
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                sort_order: { type: integer }
                duration_sec: { type: integer }
                zone_id: { type: [string, "null"] }
                muted:
                  type: boolean
                  description: >
                    Mute this item's audio. Applied to playing devices in real time
                    (device:mute-changed) and persisted into the published snapshot, so it
                    also takes effect on the next playlist load. (#129)
      responses:
        '200': { description: Updated item. }
    delete:
      tags: [assignments]
      summary: Delete a playlist item
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200': { description: '{ success, content_id }.' }

  # ------------------------------------------------------------------ devices
  /devices:
    get:
      tags: [devices]
      summary: List devices in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Array of devices.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Device' }
  /devices/{id}:
    get:
      tags: [devices]
      summary: Get a device with telemetry and assignments
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Device detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Device' }
        '404': { description: Not found. }
    put:
      tags: [devices]
      summary: Update a device
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                notes: { type: string }
                timezone: { type: string }
                orientation: { type: string }
                default_content_id: { type: string }
                layout_id: { type: [string, "null"] }
      responses:
        '200':
          description: Updated device.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Device' }
    delete:
      tags: [devices]
      summary: Delete a device
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }

  # ------------------------------------------------------------------ layouts
  /layouts:
    get:
      tags: [layouts]
      summary: List layouts (workspace + templates)
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - name: templates
          in: query
          schema: { type: string }
          description: '"true" to list only templates.'
      responses:
        '200':
          description: Array of layouts with zones.
    post:
      tags: [layouts]
      summary: Create a layout
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                width: { type: integer }
                height: { type: integer }
                zones:
                  type: array
                  items: { $ref: '#/components/schemas/Zone' }
      responses:
        '201':
          description: Created layout.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Layout' }
  /layouts/{id}:
    get:
      tags: [layouts]
      summary: Get a layout with zones
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Layout with zones.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Layout' }
    put:
      tags: [layouts]
      summary: Update a layout (and optionally replace zones)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                width: { type: integer }
                height: { type: integer }
                zones:
                  type: array
                  items: { $ref: '#/components/schemas/Zone' }
      responses:
        '200': { description: Updated layout. }
    delete:
      tags: [layouts]
      summary: Delete a layout
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /layouts/{id}/zones:
    post:
      tags: [layouts]
      summary: Add a zone to a layout
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Zone' }
      responses:
        '201':
          description: Created zone.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Zone' }
  /layouts/{id}/zones/{zoneId}:
    put:
      tags: [layouts]
      summary: Update a zone
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: zoneId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Zone' }
      responses:
        '200': { description: Updated zone. }
    delete:
      tags: [layouts]
      summary: Delete a zone
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: zoneId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /layouts/{id}/duplicate:
    post:
      tags: [layouts]
      summary: Duplicate a layout into the current workspace
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        '201': { description: Duplicated layout. }
  /layouts/device/{deviceId}:
    put:
      tags: [layouts]
      summary: Assign a layout to a device
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                layout_id: { type: [string, "null"] }
      responses:
        '200': { description: '{ success }.' }

  # ------------------------------------------------------------------- groups
  /groups:
    get:
      tags: [groups]
      summary: List device groups in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200':
          description: Array of groups.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Group' }
    post:
      tags: [groups]
      summary: Create a device group
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        '201':
          description: Created group.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Group' }
  /groups/{id}:
    put:
      tags: [groups]
      summary: Update a group
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                color: { type: string }
      responses:
        '200': { description: Updated group. }
    delete:
      tags: [groups]
      summary: Delete a group (converts group schedules to per-device)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success, schedules_converted, devices }.' }
  /groups/{id}/devices:
    get:
      tags: [groups]
      summary: List devices in a group
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Array of devices. }
    post:
      tags: [groups]
      summary: Add a device to a group
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id]
              properties:
                device_id: { type: string }
      responses:
        '201': { description: '{ success, playlist_id }.' }
  /groups/{id}/devices/{deviceId}:
    delete:
      tags: [groups]
      summary: Remove a device from a group
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /groups/{id}/assign-content:
    post:
      tags: [groups]
      summary: Add content to every device in a group
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content_id]
              properties:
                content_id: { type: string }
                duration_sec: { type: integer }
      responses:
        '200': { description: '{ success, devices_updated }.' }
  /groups/{id}/assign-playlist:
    post:
      tags: [groups]
      summary: Assign a playlist to every device in a group
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [playlist_id]
              properties:
                playlist_id: { type: string }
      responses:
        '200': { description: '{ success, devices_updated }.' }
  /groups/{id}/command:
    post:
      tags: [groups]
      summary: Send an operational command to every device in a group
      description: |
        Sends reboot / shutdown / screen on/off / launch / update to all devices
        in the group. Requires scope: full (the highest scope; tokens with only
        read or write are rejected).
      x-required-scope: full
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [screen_on, screen_off, launch, update, reboot, shutdown]
                payload: { type: object }
      responses:
        '200': { description: '{ success, sent, offline, total, results }.' }

  # ---------------------------------------------------------------------- pip
  /pip:
    post:
      tags: [pip]
      summary: Show a picture-in-picture overlay on a device or group
      description: |
        Pushes an image or web overlay onto a device (or every device in a group) on top
        of the running playlist, in real time (device:pip-show). The player fetches `uri`
        itself (same trust model as remote_url content; the server does not proxy it). A
        `web` overlay renders an arbitrary page in an iframe, so this requires scope: full. (#109)
      x-required-scope: full
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id, type, uri]
              properties:
                device_id: { type: string, description: A device id OR a group id (expanded to its members). }
                type: { type: string, enum: [image, web] }
                uri: { type: string, description: Absolute http(s) URL the player fetches directly. }
                position: { type: string, enum: [top-left, top-right, bottom-left, bottom-right, center], default: top-right }
                width: { type: integer, description: 'px (40-3840)', default: 480 }
                height: { type: integer, description: 'px (40-3840)', default: 360 }
                duration: { type: integer, description: 'seconds; 0 = until cleared (0-86400)', default: 0 }
                title: { type: string }
                title_color: { type: string, description: '#RRGGBB' }
                background_color: { type: string, description: '#RRGGBB (transparency via opacity)' }
                opacity: { type: number, description: '0-1', default: 1 }
                border_radius: { type: integer, description: 'px (0-512)', default: 0 }
                close_button: { type: boolean }
      responses:
        '200': { description: '{ success, pip_id, target, sent, offline, total, results }.' }
        '400': { description: Validation error (type / uri scheme / position / numeric bounds / color). }
        '404': { description: Device or group not found in this workspace. }
    delete:
      tags: [pip]
      summary: Clear a picture-in-picture overlay
      description: 'Clears the overlay on a device or group (device:pip-clear). Requires scope: full. (#109)'
      x-required-scope: full
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id]
              properties:
                device_id: { type: string, description: A device id OR a group id. }
                pip_id: { type: string, description: Omit to clear whatever overlay is showing. }
      responses:
        '200': { description: '{ success, target, sent, offline, total, results }.' }
        '404': { description: Device or group not found in this workspace. }
  /pip/clear:
    post:
      tags: [pip]
      summary: Clear a picture-in-picture overlay (alias for DELETE /pip)
      description: 'Same as DELETE /pip; provided for clients that prefer POST. Requires scope: full. (#109)'
      x-required-scope: full
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_id]
              properties:
                device_id: { type: string, description: A device id OR a group id. }
                pip_id: { type: string, description: Omit to clear whatever overlay is showing. }
      responses:
        '200': { description: '{ success, target, sent, offline, total, results }.' }
        '404': { description: Device or group not found in this workspace. }

  # ---------------------------------------------------------------- schedules
  /schedules:
    get:
      tags: [schedules]
      summary: List schedules (filterable)
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, schema: { type: string } }
        - { name: group_id, in: query, schema: { type: string } }
        - { name: start, in: query, schema: { type: string } }
        - { name: end, in: query, schema: { type: string } }
      responses:
        '200':
          description: Array of schedules.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Schedule' }
    post:
      tags: [schedules]
      summary: Create a schedule (targets one device OR one group)
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [start_time, end_time]
              properties:
                device_id: { type: string }
                group_id: { type: string }
                zone_id: { type: string }
                content_id: { type: string }
                widget_id: { type: string }
                layout_id: { type: string }
                playlist_id: { type: string }
                title: { type: string }
                start_time: { type: string }
                end_time: { type: string }
                timezone: { type: string }
                recurrence: { type: string }
                recurrence_end: { type: string }
                priority: { type: integer }
                color: { type: string }
      responses:
        '201':
          description: Created schedule.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Schedule' }
  /schedules/device/{deviceId}:
    get:
      tags: [schedules]
      summary: Get schedules for a device (device + group level)
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Array of schedules. }
  /schedules/week:
    get:
      tags: [schedules]
      summary: Expanded week view for a device (resolves recurrences)
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, required: true, schema: { type: string } }
        - { name: date, in: query, schema: { type: string } }
      responses:
        '200': { description: Array of schedule instances. }
  /schedules/{id}:
    put:
      tags: [schedules]
      summary: Update a schedule
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Schedule' }
      responses:
        '200': { description: Updated schedule. }
    delete:
      tags: [schedules]
      summary: Delete a schedule
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }

  # -------------------------------------------------------------------- walls
  /walls:
    get:
      tags: [walls]
      summary: List video walls (with attached devices)
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200': { description: Array of walls. }
    post:
      tags: [walls]
      summary: Create a video wall
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                grid_cols: { type: integer }
                grid_rows: { type: integer }
                bezel_h_mm: { type: number }
                bezel_v_mm: { type: number }
                playlist_id: { type: string }
      responses:
        '201': { description: Created wall. }
  /walls/{id}:
    get:
      tags: [walls]
      summary: Get a video wall with devices
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Wall with devices. }
    put:
      tags: [walls]
      summary: Update a video wall
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                grid_cols: { type: integer }
                grid_rows: { type: integer }
                bezel_h_mm: { type: number }
                bezel_v_mm: { type: number }
                sync_mode: { type: string }
                leader_device_id: { type: string }
                content_id: { type: string }
                playlist_id: { type: string }
      responses:
        '200': { description: Updated wall. }
    delete:
      tags: [walls]
      summary: Delete a video wall
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /walls/{id}/devices:
    put:
      tags: [walls]
      summary: Set the wall's device grid positions (replaces member set)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [devices]
              properties:
                devices:
                  type: array
                  items:
                    type: object
                    properties:
                      device_id: { type: string }
                      grid_col: { type: integer }
                      grid_row: { type: integer }
                      rotation: { type: integer }
                      canvas_x: { type: number }
                      canvas_y: { type: number }
                      canvas_width: { type: number }
                      canvas_height: { type: number }
      responses:
        '200': { description: Updated wall with devices. }
  /walls/{id}/content:
    put:
      tags: [walls]
      summary: Set wall content (legacy single-video path)
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                content_id: { type: string }
      responses:
        '200': { description: '{ success }.' }
  /walls/{id}/device-config/{deviceId}:
    get:
      tags: [walls]
      summary: Get wall config for a specific member device
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: deviceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Per-device wall config. }

  # ------------------------------------------------------------------ reports
  /reports/plays:
    get:
      tags: [reports]
      summary: Query play logs
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, schema: { type: string } }
        - { name: content_id, in: query, schema: { type: string } }
        - { name: start, in: query, schema: { type: string } }
        - { name: end, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer } }
      responses:
        '200': { description: Array of play-log rows. }
  /reports/summary:
    get:
      tags: [reports]
      summary: Aggregated play summary report
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, schema: { type: string } }
        - { name: start, in: query, schema: { type: string } }
        - { name: end, in: query, schema: { type: string } }
        - { name: group_by, in: query, schema: { type: string } }
      responses:
        '200':
          description: 'Summary object: overall, by_content, by_device, by_hour, by_day.'
  /reports/export:
    get:
      tags: [reports]
      summary: Export proof-of-play as CSV
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, schema: { type: string } }
        - { name: start, in: query, schema: { type: string } }
        - { name: end, in: query, schema: { type: string } }
      responses:
        '200':
          description: CSV file.
          content:
            text/csv:
              schema: { type: string }
  /reports/uptime:
    get:
      tags: [reports]
      summary: Device uptime report
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, schema: { type: string } }
        - { name: start, in: query, schema: { type: string } }
        - { name: end, in: query, schema: { type: string } }
      responses:
        '200': { description: Array of per-device uptime rows. }

  # ------------------------------------------------------------------ widgets
  /widgets:
    get:
      tags: [widgets]
      summary: List widgets in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200':
          description: Array of widgets.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Widget' }
    post:
      tags: [widgets]
      summary: Create a widget
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [widget_type, name]
              properties:
                widget_type: { type: string }
                name: { type: string }
                config: { type: object }
      responses:
        '201':
          description: Created widget.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Widget' }
  /widgets/preview:
    post:
      tags: [widgets]
      summary: Render an unsaved widget config to HTML (non-persisting)
      description: 'Requires scope: write (any POST needs write under the scope ladder); renders to HTML without persisting anything.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [widget_type]
              properties:
                widget_type: { type: string }
                config: { type: object }
      responses:
        '200':
          description: Rendered widget HTML.
          content:
            text/html:
              schema: { type: string }
  /widgets/{id}:
    get:
      tags: [widgets]
      summary: Get a widget
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Widget.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Widget' }
    put:
      tags: [widgets]
      summary: Update a widget
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                config: { type: object }
      responses:
        '200': { description: Updated widget. }
    delete:
      tags: [widgets]
      summary: Delete a widget
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /widgets/{id}/render:
    get:
      tags: [widgets]
      summary: Render a widget as an HTML page (public)
      description: |
        Unauthenticated device-render endpoint. Players fetch this HTML directly,
        so no token or scope is required.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Rendered widget HTML.
          content:
            text/html:
              schema: { type: string }

  # -------------------------------------------------------------------- kiosk
  /kiosk:
    get:
      tags: [kiosk]
      summary: List kiosk pages in the current workspace
      description: 'Requires scope: read.'
      x-required-scope: read
      responses:
        '200': { description: Array of kiosk pages. }
    post:
      tags: [kiosk]
      summary: Create a kiosk page
      description: 'Requires scope: write.'
      x-required-scope: write
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                config: { type: object }
      responses:
        '201': { description: Created kiosk page. }
  /kiosk/{id}:
    get:
      tags: [kiosk]
      summary: Get a kiosk page
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Kiosk page. }
    put:
      tags: [kiosk]
      summary: Update a kiosk page
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                config: { type: object }
      responses:
        '200': { description: Updated kiosk page. }
    delete:
      tags: [kiosk]
      summary: Delete a kiosk page
      description: 'Requires scope: write.'
      x-required-scope: write
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: '{ success }.' }
  /kiosk/{id}/render:
    get:
      tags: [kiosk]
      summary: Render a kiosk page as an HTML page (public)
      description: |
        Unauthenticated device-render endpoint. Devices fetch this HTML directly,
        so no token or scope is required.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Rendered kiosk HTML.
          content:
            text/html:
              schema: { type: string }

  # ----------------------------------------------------------------- activity
  /activity:
    get:
      tags: [activity]
      summary: Get the activity log
      description: 'Requires scope: read.'
      x-required-scope: read
      parameters:
        - { name: device_id, in: query, schema: { type: string } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200': { description: Array of activity entries. }
