Provisioning Domain
Overview
The Provisioning Domain handles device lifecycle management for Digital Signage displays (LG WebOS, Android, etc.). It manages device pairing via OTP codes, real-time status monitoring via heartbeats, remote playback control via MQTT, and IP-based geolocation enrichment.
Beyond the core pairing/heartbeat/play-stop loop, it also owns:
- Broadcast to Space — push content to every device in a Space at once, bypassing per-channel scheduling.
- Composite rendering — server-side FFmpeg merging of multiple zones into a single video when a device can't render multi-zone layouts itself.
- Test playback — push a temporary preview playlist to a device for N minutes, then auto-revert.
- Playout channel groups — an alternate
appModefor devices acting as linear-TV players rather than digital-signage screens. - Approvals — a generic pending/approved/rejected gate that broadcast actions can be routed through instead of executing immediately.
Architecture
┌───────────────────────────────────────────────────────────────┐
│ Provisioning Domain │
│ │
│ Physical Screen Platform │
│ ┌─────────────┐ │
│ │ Display │──── 1. generate-code ──▶ provisioning_codes │
│ │ (WebOS / │◀─── shows 6-digit OTP │
│ │ Android) │ │
│ │ │──── 2. claim-code ──────▶ devices (created) │
│ │ │◀─── deviceToken (JWT) │
│ │ │ │
│ │ │──── 3. heartbeat-public ▶ devices.last_seen_at│
│ │ │ │
│ │ │◀─── MQTT: play ───────── /devices/:id/play │
│ │ │◀─── MQTT: STOP ────────── /devices/:id/stop │
│ └─────────────┘ │
│ │
│ Device status = 'Online' if last_seen_at > NOW() - 5 min │
└───────────────────────────────────────────────────────────────┘
MQTT Topics:
| Topic | Trigger | Payload |
|---|---|---|
tellyboard/devices/{deviceId}/play |
POST /devices/:id/play, .../composite, .../test-play, POST /spaces/:id/broadcast |
{ command: "PLAY", ... } — items, zones, or a single composited video, depending on path |
tellyboard/devices/{deviceId}/player/command |
POST /devices/:id/stop, .../test-play/stop (when no bound channel) |
"STOP" string |
tellyboard/devices/{deviceId}/player/command |
POST /devices/:id/channels/publish/:groupId, POST /spaces/:id/channels/publish/:groupId |
{ command: "SET_CHANNEL_GROUPS", ... } (playout-mode devices only) |
Request Headers:
| Header | Description |
|---|---|
X-Organization-ID |
Required on all management endpoints |
X-Workspace-ID |
Optional — filters device list/get to workspace scope |
X-User-ID |
Required on approval review (PATCH /approvals/:id); used to stamp reviewedById |
Data Model
Device
The core entity representing a registered physical display.
| Field | Type | Notes |
|---|---|---|
id |
string | UUID |
organizationId |
string | Tenant scope |
name |
string | Display name |
serialNumber |
string | Hardware serial; set to SN-{code} on claim |
status |
string | Derived: Online if last_seen_at > NOW() - 5 min, else Offline |
lastSeenAt |
*time.Time | Updated by heartbeat |
currentPlaylistId |
*string | Last playlist pushed to device; cleared on stop |
locationId |
*string | Links to Spatial domain locations.id (space type) |
volume |
int | Default 100 on claim |
orientation |
string | Default landscape on claim |
isRemoteControlEnabled |
bool | Default true on claim |
isPanelControlEnabled |
bool | Default true on claim |
wolEnabled / dpmEnabled |
bool | Wake-on-LAN / Display Power Management |
dpmSignalType |
string | DPM signaling mode |
ipAddress / macAddress |
string | Network identifiers |
manufacturer / model / playerVersion / firmwareVersion / osVersion |
*string | Hardware metadata |
platformName / platformVersion |
string | Player platform metadata |
screenSize |
*string | Physical screen size |
storageUsedBytes / storageTotalBytes |
*int64 | Storage stats |
continent, country, countryCode, regionName, city, isp … |
string | Geo fields, populated by /geo endpoint |
latitude / longitude |
*float64 | Set on device create/update; not populated by /geo (which only fills continent/country/city/isp/etc.) |
workspaceId |
*string | Optional workspace scope; filters GET/PUT/DELETE /devices* when X-Workspace-ID header is present |
description |
*string | Free-text device description |
timezone |
string | Device timezone |
deviceType |
string | Default "signage" |
approvalStatus |
string | Default "pending" on create; displayed as "approved" via fallback on read |
appMode |
string | "tellyboard" (default, digital signage) or "playout" (linear channel mode) — see Playout Channel Groups. Set via ClaimCodePayload.mode at claim time or via PUT /devices/:id. Gates POST .../channels/publish/:groupId. |
playoutChannelGroupId |
*string | Currently assigned playout channel group; set by POST /devices/:id/channels/publish/:groupId |
locationName |
string | Computed join field (assigned space's name) |
locationPath |
string | Computed: venue → building → floor → location → endpoint names joined with " > " |
locationCountryCode |
string | Computed by walking the location hierarchy |
playbackGroupId / policyGroupId / operationGroupId |
*string | Present on the model; not yet read or written by any handler in this domain |
status is usually computed, but not always
GET handlers compute status on the fly ('Online' if last_seen_at is within 5 minutes, else 'Offline') rather than reading a stored column. HandleUpdateDevice, however, does let a caller set status directly, and HandleRelinkDevice always sets it to 'Online' — so a stale direct write can be overridden by the computed value on the next read.
ClaimCodePayload
Request body for /devices/claim-code.
| Field | Type | Notes |
|---|---|---|
code |
string | 6-digit OTP shown on physical screen |
name |
string | Friendly name to assign the new device |
mode |
string | Optional — "tellyboard" (default) or "playout"; sets the new device's appMode |
provisioning_codes (DB table, no Go model)
Tracks OTP sessions.
| Column | Notes |
|---|---|
id |
UUID — used as provisioningId for polling |
code |
6-digit numeric OTP |
expires_at |
10 minutes from generation |
device_id |
NULL until claimed; set on claim/relink |
organization_id |
Set on claim |
Broadcast / Composite / Test-play / Playout / Approval models
| Type | Fields |
|---|---|
BroadcastItem |
mediaId, name, type, url, storageKey, duration |
BroadcastRequest |
items: BroadcastItem[], isOverride: bool, playAt: *time.Time |
CompositeZone |
zoneId, filePath, widthPx, heightPx, xPx, yPx, duration |
CompositeRequest |
channelId, screenW (default 1920), screenH (default 1080), totalSec (default 30), zones: CompositeZone[] |
TestPlayRequest |
playlistId, minutes (1–240) |
PlayoutChannel |
channelId, chno, name, logo, thumbnail, type ("live"), url, description, durationSec?, positionSec? |
PlayoutGroup |
groupId, groupName, channels: PlayoutChannel[] |
CreateApprovalRequestPayload |
kind, targetId, targetName, requestedById, requestedBy, scope, summary, payload (any) |
ApprovalRequest |
id, organizationId, kind, targetId, targetName, requestByID, requestByName, scope, summary, payload, status ("pending"\|"approved"\|"rejected"), reviewedById, reviewedAt, createdAt, updatedAt |
Routes
| Method | Path | Handler | Description |
|---|---|---|---|
| GET | /devices |
HandleGetDevices |
List all devices for org, optional workspace filter |
| POST | /devices |
HandleCreateDevice |
Create device manually (name, description, serialNumber) |
| GET | /devices/:deviceId |
HandleGetDeviceByID |
Get single device |
| PUT | /devices/:deviceId |
HandleUpdateDevice |
Update device settings and metadata |
| DELETE | /devices/:deviceId |
HandleDeleteDevice |
Delete device; nullifies linked provisioning codes |
| POST | /devices/generate-code |
HandleGenerateDeviceCode |
Generate a 6-digit OTP pairing code (expires in 10 min) |
| POST | /devices/claim-code |
HandleClaimDeviceCode |
Claim OTP → creates device, links provisioning code (transactional) |
| POST | /devices/relink |
HandleRelinkDevice |
Relink an existing device to a new OTP code |
| GET | /devices/check-claim/:provisioningId |
HandleCheckClaimStatus |
Poll claim status: pending / claimed / expired |
| POST | /devices/heartbeat-public |
HandleDeviceHeartbeatPublic |
Update last_seen_at (no auth required — called by device) |
| POST | /devices/:deviceId/play |
HandleSendPlaylist |
Normalize and push playlist via MQTT; updates current_playlist_id |
| POST | /devices/:deviceId/stop |
HandleStopPlayback |
Send MQTT STOP command; clears current_playlist_id |
| POST | /devices/:deviceId/geo |
HandleGetGeoInfo |
Lookup geo via ip-api.com using device IP; updates geo fields |
| PATCH | /devices/:deviceId/location |
HandleAssignDeviceLocation |
Assign device to a spatial location (space) |
| POST | /devices/:deviceId/composite |
HandleCompositeAndPlay |
FFmpeg-render zones from local file paths into one video, push to device |
| POST | /devices/:deviceId/test-play |
HandleTestPlayOnDevice |
Push a temporary preview playlist for N minutes, then auto-revert |
| POST | /devices/:deviceId/test-play/stop |
HandleStopTestOnDevice |
Cancel the pending revert timer and revert immediately |
| GET | /devices/:deviceId/channels/groups |
HandleGetPlayoutChannelGroups |
Get the device's assigned playout channel group (linear-TV mode) |
| POST | /devices/:deviceId/channels/publish/:groupId |
HandlePublishChannelGroupToDevice |
Push SET_CHANNEL_GROUPS to one playout-mode device |
| POST | /spaces/:spaceId/channels/publish/:groupId |
HandlePublishChannelGroupToSpace |
Push SET_CHANNEL_GROUPS to every playout-mode device bound to a space |
| POST | /spaces/:spaceId/broadcast |
HandleBroadcastToSpace |
Push content to every device bound to a space at once |
| GET | /channels/:channelId/activities |
HandleGetChannelActivities |
Read the activity log (FFmpeg/download/composite events) for a channel |
| POST | /approvals |
HandleCreateApprovalRequest |
Create a pending approval request |
| GET | /approvals |
HandleGetApprovalRequests |
List approval requests, optionally filtered by status |
| PATCH | /approvals/:requestId |
HandleUpdateApprovalRequestStatus |
Approve/reject a request; approving fires the underlying broadcast(s) |
Two routes live outside routes.go
GET /channels/:channelId/activities and POST /spaces/:spaceId/broadcast are registered directly in main.go rather than in domain/provisioning/routes.go — functionally identical, just easy to miss when scanning the routes file alone.
Key Creation Flows
1. Device Pairing (OTP Flow)
# Step 1: Screen requests a pairing code (called from the display hardware)
POST /devices/generate-code
→ Generates 6-digit numeric OTP
→ Stores in provisioning_codes with 10-minute TTL
→ Returns: { "provisioningId": "uuid", "code": "483921", "expiresAt": "..." }
# Step 2: Operator enters the code on the web platform
POST /devices/claim-code
Headers: X-Organization-ID
Body: { "code": "483921", "name": "Lobby Screen A" }
→ Validates code is not expired and not yet claimed
→ Creates device (serialNumber = "SN-483921", volume=100, orientation=landscape)
→ Links provisioning_codes.device_id = new device id
→ Returns: { "id": "device-uuid", "name": "Lobby Screen A", "status": "Online" }
# Step 3: Screen polls until claimed
GET /devices/check-claim/:provisioningId
→ Returns { "status": "pending" } (while waiting)
→ Returns { "status": "claimed", "deviceId": "...", "deviceToken": "JWT" }
→ Returns { "status": "expired" } (after 10 min)
2. Heartbeat (Device → Platform)
POST /devices/heartbeat-public
Body: { "deviceId": "device-uuid" }
→ No auth required (called directly from device)
→ Updates devices.last_seen_at = NOW()
→ Device is considered 'Online' for 5 minutes after last heartbeat
3. Push Playlist to Device
POST /devices/:deviceId/play
Headers: X-Organization-ID
Body: {
"id": "playlist-uuid",
"playlistName": "Morning Show",
"items": [
{ "mediaId": "...", "name": "...", "mediaType": "video", "duration": 30, "url": "...", "storageKey": "..." }
]
}
→ Normalizes items (supports both "id"/"mediaId" and "type"/"mediaType" field names)
→ Also supports "zones": [{ "items": [...] }] for multi-zone layouts
→ Updates devices.current_playlist_id
→ Publishes to MQTT topic: tellyboard/devices/{deviceId}/play
→ Returns: { "success": true, "message": "Playlist sent and saved" }
POST /devices/:deviceId/stop
→ Clears current_playlist_id
→ Publishes "STOP" to MQTT topic: tellyboard/devices/{deviceId}/player/command
→ Returns: { "status": "stopped" }
4. Geolocation Enrichment
POST /devices/:deviceId/geo
Headers: X-Organization-ID
→ Uses device's stored ip_address, or falls back to request IP / X-Forwarded-For
→ Private IPs (127.x, 192.168.x, 10.x) query ip-api.com without IP (uses server IP)
→ Calls: http://ip-api.com/json/{ip}
→ Updates: continent, country, countryCode, regionName, city, isp, ip_address
→ Returns geo fields on success
5. Relink Device to New Code
POST /devices/relink
Headers: X-Organization-ID
Body: { "code": "new-otp", "deviceId": "existing-device-uuid" }
→ Validates OTP is valid and unclaimed
→ Validates device belongs to org
→ Links provisioning_codes.device_id = existing device
→ Sets devices.status = 'Online', last_seen_at = NOW()
→ Use case: device was factory reset or swapped hardware
6. Broadcast to Space
Pushes the same content to every device bound to a Space in one call, bypassing per-channel scheduling and per-device playlist assignment entirely. Used for one-off or "always-on default" pushes — this is the mechanism behind TellyboardAdmin's "Publish Direct" / "Direct Assignment" action.
POST /spaces/:spaceId/broadcast
Headers: X-Organization-ID
Body: {
"items": [
{ "mediaId": "...", "name": "...", "type": "video", "url": "...", "storageKey": "...", "duration": 30 }
],
"isOverride": true,
"playAt": "2026-08-07T12:00:00Z" ← optional
}
→ Verifies spaceId is a 'space'-type location in this org (404 otherwise)
→ Resolves item URLs (storageKey → proxy URL, or rewrites dev/local hosts)
→ Finds every device bound to the space via `endpoints`
→ Publishes MQTT PLAY concurrently (5s timeout/device) to
topic: "tellyboard/devices/{deviceId}/play"
payload: { command: "PLAY", playlistName: "Space Broadcast", isOverride, items, playAt? }
→ Returns: { "success": true, "sentCount": <n>, "total": <n> }
No devices in the space, or MQTT down
Returns 404 "no devices found in this space" if the space has no bound devices, or 503 "mqtt broker not connected, please retry" if the broker isn't reachable.
Gated by permission, sometimes
TellyboardAdmin checks the ws:playlist:publish permission client-side before calling this endpoint directly; if the current user lacks it, the UI instead calls POST /approvals and routes the same payload through the Approval Flow below. The API itself does not enforce this — see the Catalogue domain's note on CMS routes having no server-side auth.
7. Composite Rendering (multi-zone → single video)
Some devices can't render a multi-zone layout themselves, so CoreAPI can merge several zones into one MP4 server-side (FFmpeg overlay/concat filters) and send that as a normal single-item PLAY. There are two ways this happens:
A. Manual endpoint — POST /devices/:deviceId/composite
Body: {
"channelId": "...",
"screenW": 1920, "screenH": 1080, "totalSec": 30,
"zones": [
{ "zoneId": "z1", "filePath": "/local/path/a.mp4", "widthPx": 960, "heightPx": 1080, "xPx": 0, "yPx": 0, "duration": 15 },
{ "zoneId": "z2", "filePath": "/local/path/b.mp4", "widthPx": 960, "heightPx": 1080, "xPx": 960, "yPx": 0, "duration": 15 }
]
}
→ Zone filePaths are LOCAL SERVER FILE PATHS, not media IDs/URLs
→ Shells out to ffmpeg, writes ./media-assets/uploads/composite/composite_{deviceId}_{ts}.mp4
→ Publishes MQTT PLAY with the merged video (playlistId: "composite-pl-{ts}")
→ Returns: { "success": true, "url": "https://.../composite_...mp4", "message": "Composite sent to device" }
→ 500 { "error": "FFmpeg failed", "detail": "..." } on encode failure
B. Automatic, inside POST /devices/:deviceId/play — when the resolved channel has a multi-zone layout template, HandleSendPlaylist picks a render mode per request instead of always sending zones as-is:
| Mode | When | Behavior |
|---|---|---|
none |
No valid zone content | 400 {"error":"No valid content"} |
single |
Exactly one valid zone | Sends that zone's items as a flat PLAY (no zones/layout) |
direct |
Any zone has .mpd content, or no PDFs mixed in |
Sends { command:"PLAY", layout, zones } untouched — the device renders the layout itself |
composite |
Multiple video zones, or PDFs mixed with other content in a zone | FFmpeg-renders all zones into one video (downloading remote media as needed, looping shorter zones to match the longest), then PLAYs the merged file |
Composite mode adds:
- De-duplication — a SHA-256 signature of the layout+zones skips re-rendering if nothing changed since the last render for that device (
mode: "composite-unchanged"). - In-flight guard — a concurrent request for the same device while a render is running is skipped, not queued (
mode: "composite-skipped"). - Cleanup — the rendered file is deleted 30 seconds after publishing (unlike path A above).
- Activity logging —
FFMPEG_START/SUCCESS/ERROR,DOWNLOAD_START/SUCCESS, andCOMPOSITE_GENERATEDevents are recorded per channel, readable viaGET /channels/:channelId/activities.
8. Test Playback (Preview on a Live Device)
Lets an operator push a temporary preview playlist to a device without touching its real assigned channel/playlist, and have it auto-revert.
POST /devices/:deviceId/test-play
Headers: X-Organization-ID
Body: { "playlistId": "playlist-uuid", "minutes": 10 }
→ Validates minutes is 1–240, playlist exists and has items
→ Determines what the device should revert to (its currently bound channel, if any)
→ Publishes MQTT PLAY (id: "test-{ts}", playlistName: "Test on Device", isOverride: true)
→ Schedules an in-memory timer to auto-revert after `minutes`
→ Returns: { "success": true, "message": "Test playback started for 10 minute(s)", "revertsTo": "channel-uuid-or-empty", "deviceId": "..." }
POST /devices/:deviceId/test-play/stop
→ Cancels the pending timer and reverts immediately
→ Returns: { "success": true, "message": "Test stopped, device reverted", "revertsTo": "..." }
Revert timers are in-memory only
A server restart during an active test window loses the scheduled revert — the device stays on the test playlist until manually stopped (.../test-play/stop) or another play command is issued. Reverting re-invokes the device's own /play endpoint internally, so normal channel/layout/composite resolution runs again rather than a raw MQTT shortcut.
9. Playout Channel Groups (Linear-TV Mode)
A device with appMode: "playout" behaves as a linear channel player instead of a digital-signage screen. Channel group data is proxied from an internal playback service and pushed to devices as an MQTT SET_CHANNEL_GROUPS command.
GET /devices/:deviceId/channels/groups
→ Looks up the device's assigned playoutChannelGroupId; returns [] if none assigned
→ Fetches the group from {INTERNAL_API_URL}/api/v1/playback/channel-groups/:id
→ Refuses (returns []) unpublished/draft groups unless PLAYOUT_ALLOW_DRAFT=1
→ Enriches each channel's logo/thumbnail/description from playout_channel_meta
→ Returns: [{ groupId, groupName, channels: [{ channelId, chno, name, logo, thumbnail, type:"live", url, description }] }]
POST /devices/:deviceId/channels/publish/:groupId
→ Requires the device to have appMode='playout' (else no-op/error)
→ Publishes MQTT { command: "SET_CHANNEL_GROUPS", ... } to tellyboard/devices/{deviceId}/player/command
→ Updates devices.playoutChannelGroupId
→ Returns: { "success": true }
POST /spaces/:spaceId/channels/publish/:groupId
→ Same, fanned out to every playout-mode device bound to the space
→ Returns: { "success": true, "sentCount": <n> }
10. Approval Flow
A generic, org-scoped pending/approved/rejected gate. Currently used specifically to review Broadcast to Space requests before they actually send.
stateDiagram-v2
[*] --> pending: POST /approvals
pending --> approved: PATCH /approvals/:id { status: "approved" }
pending --> rejected: PATCH /approvals/:id { status: "rejected" }
approved --> [*]
rejected --> [*]
POST /approvals
Headers: X-Organization-ID
Body: { "kind": "broadcast", "targetId": "space-uuid", "targetName": "...", "scope": "...", "summary": "...",
"payload": { "items": [...], "spaceIds": ["..."], "endpointIds": [] } }
→ kind and targetId are required
→ Returns: 201 { "id": <n>, "status": "pending" }
GET /approvals?status=pending
→ Returns: ApprovalRequest[] ordered by createdAt desc
PATCH /approvals/:requestId
Headers: X-Organization-ID, X-User-ID
Body: { "status": "approved" | "rejected" }
→ status must be exactly "approved" or "rejected" (400 otherwise)
→ 404 if the request doesn't exist or is no longer "pending" for this org
→ Sets reviewedById (from X-User-ID header — NOT the body's decidedBy field, which is accepted but ignored), reviewedAt, status
→ On "approved": asynchronously replays payload.items to every payload.spaceIds (via the same
logic as Broadcast to Space) and payload.endpointIds (single-endpoint variant), fire-and-forget
→ Returns: { "message": "Approval request updated", "status": "approved" }
decidedBy and reviewerFeedback are accepted but not persisted
Only the reviewer's identity from the X-User-ID header is stored. If the UI needs to display who reviewed a request, read reviewedById, not anything echoed back from the request body.
Changelog
| Date | Author | Change |
|---|---|---|
| 2026-08-07 | — | Documented Broadcast to Space, Composite rendering, Test Playback, Playout channel groups (appMode), and the Approval flow; updated Device fields and routes table |
| 2026-06-17 | — | Added description, architecture, data model, creation flows |
| 2026-06-16 | Pin | Created the openapi |