mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
1898 lines
62 KiB
YAML
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 < 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 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. }
|