Skip to content

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 appMode for 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

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
This path does not delete its output file afterward.

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, and COMPOSITE_GENERATED events are recorded per channel, readable via GET /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