screentinker/docs/openapi.yaml
Claude e4c25c39df Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as
the audience sees it. Invisible while every panel is the normal way up, and
actively misleading the moment one isn't — two portrait-mounted panels standing
side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy
of every video, before the output came out right. It worked, but only after
trial and error, and it meant a portrait wall could never reuse content as-is.

Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same
convention as the per-device orientation setting), the canvas means the physical
wall, and the player works out the mapping. The geometry lives in one place,
server/lib/wall-geometry.js, because four players have to agree on it to the
pixel across a seam.

Existing walls need no migration and do not move. Every wall in the field is
rotation 0, and that case takes the original expression verbatim on all three
players rather than the algebraically-equal centre-based one — the two differ in
the last float bit, and a float's worth of disagreement between two panels is a
hairline seam down a wall that was aligned yesterday. Pinned by the first test
in wall-geometry.test.js and by wall-payload.test.js.

While a display is in a wall its panel rotation replaces its own orientation:
both describe the same physical fact, so honouring both turned the content twice.

#235: a wall replaced its members' cards, so one dead panel of a four-panel wall
was invisible from the dashboard, and inspecting a single screen meant pulling it
out of the live wall and putting it back. The wall screen now lists its panels
with live online state, a per-panel screenshot request, and a link to each
device's page; the dashboard wall card carries per-member status chips that track
socket updates.

Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts
each panel's viewport maps onto exactly its own rect of wall space, for every
rotation, plus a mixed wall and the Tizen player's hand-ported copy executed
against the canonical rule. Full server suite green (1260).

Not verified here: the Android and Tizen renders on real hardware. Kotlin
compiles clean; the maths is shared/tested, the view plumbing is not.
2026-08-06 09:46:31 -05:00

1876 lines
61 KiB
YAML

openapi: 3.1.0
info:
title: ScreenTinker Public API
version: 1.9.29
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 < write < 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
enum: [0, 90, 180, 270]
default: 0
description: >-
How this panel is physically mounted, as degrees CLOCKWISE that its image
must be turned to come out upright on the wall (the same convention as a
device's `orientation`). canvas_* are in WALL space — the wall as the
audience sees it — so a portrait-mounted 1920x1080 panel is a tall tile
with rotation 90, and content needs no pre-rotating. Anything other than
0/90/180/270 is stored as 0. While a panel is in a wall this replaces its
own `orientation`, so the two can never rotate the content twice.
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. }