screentinker/docs/openapi.yaml
ScreenTinker 9face2fdd4 Show a panel's IPv6, and size the pairing code to the screen it is on
Two field-reported gaps, unrelated except that both are about being able to
read something off a screen.

A PANEL'S IPv6 WAS NEVER COLLECTED, LET ALONE SHOWN.

DeviceInfo.getLocalIp() filters to Inet4Address, so a v6-only panel reported no
address at all and the dashboard rendered a dash for a screen that was perfectly
reachable. It now reports both stacks in their own fields: a dual-stack panel
genuinely has two addresses and either may be the one you need, so collapsing
them into one column would make it mean "whichever interface enumerated first".

Link-local (fe80::/10) is deliberately excluded. Every interface has one, they
tend to enumerate first, and none can be dialled without also knowing the zone
index — so admitting them would fill the field with a string nobody can paste
anywhere and hide the address that works. Any %iface suffix is trimmed for the
same reason. The 45-char cap the writer already applied is exactly the longest
legitimate IPv6 text form, so it needed no change.

The dashboard card renders only when a panel actually has a v6 address, rather
than showing an empty row to the overwhelmingly v4 fleet.

THE PAIRING CODE DID NOT SCALE, WHICH IS WORST WHERE IT MATTERS MOST.

Every size on the pre-playback screens was a hard-coded pixel value. A CSS pixel
covers a quarter of the screen area on a 4K panel that it does on 1080p, and a
sixteenth on 8K — so the 72px code that fills a 1080p screen is a smudge on the
4K wall it was installed on, which is where signage actually goes.

What has to stay constant is ANGULAR size, so the root font size is now
viewport-proportional and everything on those screens is a rem against it. The
code holds 6.67% of screen height at every resolution: 72px at 1080p — bit for
bit what it renders today, so nothing changes for the existing fleet — 144px at
4K, 288px at 8K. Verified in a browser rather than by arithmetic: at a 1409px
viewport the root computes to 13.0473px, which is 0.926vmin to four decimals.

vmin, not vw, because portrait-mounted panels are common here and vw would
render a 1080x1920 screen at half size. Clamped at both ends so the dashboard's
preview iframe stays legible instead of microscopic and an ultrawide does not
get silly. Applied to the web player (which BrightSign also runs) and to Tizen,
where a 1920x1080 logical viewport makes it arithmetically identical to the
values it replaces — the point being the panels where it is not.

A test asserts the scaling cannot reach playback content: the whole safety
argument is that only the chrome uses rem, and a stage or zone rule adopting it
would start resizing CONTENT, which is a worse bug than the one being fixed.
Android is untouched — its pairing code already autosizes within a dp-scaled
layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 08:06:45 -05:00

1898 lines
62 KiB
YAML

openapi: 3.1.0
info:
title: ScreenTinker Public API
version: 1.9.31
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 IPv4 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, where the platform withholds it, or on
a panel with no IPv4 address at all — see `local_ip6`.
local_ip6:
type: [string, "null"]
description: |
The device's **own IPv6 address on its local network**, reported alongside
`local_ip` rather than instead of it: a dual-stack panel has both and either may be
the one you need. Link-local addresses (`fe80::/10`) are deliberately excluded —
every interface has one and none can be reached without also knowing the zone
index, so this carries a global or unique-local address or nothing. Null on players
that do not report it and on IPv4-only panels.
# --- 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
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
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
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
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
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
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. }