- TV slots, the pairing flow (operator code → TV adoption), heartbeat reporting, the orphan-claim queue, and the admin dashboard at
/admin/tv-healththat surfaces the whole thing - A single Durable Object (
TV_PAIRING) owns slot identity, pairing tokens, claims, and state-transition history; heartbeats land in R2; the dashboard joins them in one fan-out query - Adds a self-reporting Pi health channel (
/api/pi-status) and platform reachability probes (KV + R2 + CF edge) to the same dashboard so the operator can answer “is the worker healthy?” without leaving the page - Admin pages verify the Cloudflare Access identity and enforce Pub permissions in the Worker (see auth-model)
Sources: worker.js (TvPairingCoordinator class, handleAdminTvDashboardApi, handleAdminTvHealthRawPage, handlePiStatusIngest, probePlatform), worker/lib/tv-slot-state.js, public/tv/pair/index.html, public/tv/script.js, pi/pi-status-reporter/main.py
End-to-end picture
flowchart LR subgraph "TV browser (Tizen / Chrome)" TVAPP["/tv/ app"] -->|heartbeat ping<br/>~every 60s| HB PAIR["/tv/pair page"] -->|POST /api/tv/pair/start| PSTART PAIR -->|GET /api/tv/pair/poll| PPOLL end subgraph "Pi (private AV network)" PIREP["pi-status-reporter<br/>(systemd, 30s)"] -->|POST /api/pi-status<br/>Bearer NOW_PLAYING_INGEST_TOKEN| PISTATUS end subgraph "Worker" HB["/api/tv-health"] --> R2["R2: IMAGE_MIRROR_BUCKET<br/>tv-health/screens/*.json"] PSTART["/api/tv/pair/start"] --> DO PPOLL["/api/tv/pair/poll"] --> DO PISTATUS["/api/pi-status"] --> KV[("KV: DJ_FLASH<br/>pi:status (TTL 10m)")] DO[("DO: TV_PAIRING<br/>slots, claims, tokens")] end subgraph "Admin browser" DASH["/admin/tv-health<br/>(dashboard)"] -->|GET /api/admin/tv/dashboard| DASHAPI SLOTS["/admin/slots<br/>(CRUD)"] -->|GET /api/admin/tv/slots| DO DASHAPI -->|Promise.all| DO DASHAPI -->|Promise.all| R2 DASHAPI -->|Promise.all| KV DASHAPI -->|Promise.all| PROBE["probePlatform()<br/>(KV + R2 + request.cf)"] end
Sources: worker.js (route table around line 530, handleAdminTvDashboardApi)
Slot model
A slot is a stable identity for a physical screen position (e.g. Bar Left, Bar Right). Slots are operator-managed in /admin/slots. A TV (browser session with its own screenId in localStorage) becomes the occupant of a slot through pairing.
The DO (TvPairingCoordinator) stores three kinds of records:
| Record | DO storage key | Purpose |
|---|---|---|
| Slot | slot:<label> | { label, displayName, currentToken, pairedAt, lastPairChangedAt, createdAt, healthMonitoringMuted } — identity, pairing, and whether warning counts/email ignore an intentionally parked slot |
| Pair code | code:<6-digit> | Short-lived { slotLabel, screenId, expiresAt, status } while a TV’s pair flow is open |
| Claim | claim:<screenId> | { screenId, slotLabel, mintedAt } — operator pre-assignment for a TV that isn’t currently reporting (orphan claim) |
slot:<label> is the canonical “what’s paired where” record. Tokens are opaque 32-char strings the TV stores in localStorage and presents on every heartbeat.
Sources: worker.js (TvPairingCoordinator.fetch route dispatch, slot/code/claim helpers around line 800-1300), tests/tv-pairing-coordinator.test.js
Slot lifecycle states
The dashboard derives a state per slot by joining the DO slot record with the latest matching R2 heartbeat. States are computed by computeTvSlotState in worker/lib/tv-slot-state.js:
| State | Trigger | Visual |
|---|---|---|
fresh | Valid heartbeat less than 5 minutes old | Green |
stale | Valid heartbeat from 5 minutes to less than 30 minutes old | Amber |
missing | Paired slot with no valid heartbeat, or a heartbeat at least 30 minutes old | Red |
unpaired | Slot exists with no currentToken | Grey |
State transitions are batched at the end of each dashboard query and written back to the DO via /recordStateTransitions so the history strip can show “Bar Left went stale 8 min ago”.
Parked-slot health muting
/admin/slots exposes a Monitor checkbox per slot. Turning it off writes healthMonitoringMuted = true. The slot remains in administration and the dashboard continues to expose its actual fresh / stale / missing state, but overview counts and offline-alert email exclude it. Existing and newly created slots are monitored by default until an operator explicitly mutes one.
Sources: worker/lib/tv-slot-state.js, worker.js (handleAdminTvDashboardApi step 3 around line 1630)
Pairing flow
A new TV (no token in localStorage) loads /tv/pair and walks through:
sequenceDiagram participant TV as TV browser participant W as Worker participant DO as TV_PAIRING DO participant OP as Operator (phone) TV->>W: POST /api/tv/pair/start W->>DO: /mintCode DO-->>W: { code: "478083", expiresAt } W-->>TV: { code, qrSvg, deepLinkUrl } Note over TV: Displays QR + 6-digit code loop every 2s TV->>W: GET /api/tv/pair/poll?code=478083 W->>DO: /lookupCode DO-->>W: { status: "waiting" } end OP->>W: POST /api/admin/tv/pair/complete<br/>{ code, slotLabel: "Bar Left" } W->>DO: /completePair Note over DO: Mints token, writes slot:Bar Left,<br/>updates code:478083 status=paired DO-->>W: { ok, token, slotLabel } TV->>W: GET /api/tv/pair/poll?code=478083 W->>DO: /lookupCode DO-->>W: { status: "paired", token, slotLabel } TV->>TV: localStorage.set("pub-tv:pair-token:v1", token) TV->>TV: location.replace("/tv/")
Codes expire after PAIR_CODE_TTL_MS (5 min). On expiry the TV mints a fresh one and resumes polling.
Sources: worker.js (/api/tv/pair/start, /api/tv/pair/poll, /api/admin/tv/pair/complete), public/tv/pair/index.html, tests/tv-pairing-endpoints.test.js
Heartbeat and identity
Every paired TV pings POST /api/tv-health periodically with its screenId, pairing token, and a rich payload of self-reported state:
{
"screenId": "scr-c4f8a92b",
"pairingToken": "tok-...",
"buildId": "2026-05-25-r3",
"viewport": "1920x1080",
"userAgent": "Mozilla/5.0 (... Tizen ...)",
"uptimeSeconds": 86400,
"fcpMs": 980,
"memoryUsedMb": 142,
"lastJsError": null,
// ...many more — see "Tile detail rich metrics" below
}The worker:
- Validates the token against the DO (rejects unknown tokens unless
AUTO_MIGRATE=true— see auto-pair below) - Stores the payload as
tv-health/screens/<screenId>.jsoninIMAGE_MIRROR_BUCKETR2 (one file per TV, overwritten each ping) - Appends a small event to
tv-health/history/<screenId>.jsonfor the orphan-verification check
R2 was chosen over KV because the dashboard needs to list all heartbeats and KV’s list-prefix lookup is rate-limited and eventually-consistent.
Sources: worker.js (/api/tv-health handler around line 2400), tests/tv-health-grouping.test.js
The dashboard endpoint
GET /api/admin/tv/dashboard is the single query that drives every visible section of /admin/tv-health. It fans five reads out in parallel via Promise.all:
flowchart TD REQ["GET /api/admin/tv/dashboard"] REQ --> P{"Promise.all"} P --> S1["fetchDashboardSlots(env)<br/>(DO /listSlots)"] P --> S2["fetchDashboardRecords(bucket)<br/>(R2 list + get tv-health/screens/*)"] P --> S3["fetchDashboardClaims(env)<br/>(DO /listClaims)"] P --> S4["readPiStatus(env)<br/>(KV pi:status, null when fresh-install)"] P --> S5["probePlatform(env, request)<br/>(KV+R2 ping each binding, read request.cf)"] P --> JOIN["Join slots × records by label;<br/>compute state per slot;<br/>tag remaining records as orphans"] JOIN --> RESP["{ slots[], orphans[], claims[],<br/> piStatus, cfMeta, degraded? }"]
Each side degrades independently. If R2 is unreachable the dashboard still renders DO slots (marked degraded.r2 = true); if the DO is unreachable the dashboard still renders raw heartbeats (degraded.do = true). The UI shows a yellow banner; nothing crashes.
Sources: worker.js (handleAdminTvDashboardApi ~line 1570), tests/tv-dashboard-endpoint.test.js
Orphans and the claim queue
An orphan is a heartbeat record (R2 key under tv-health/screens/) whose payload doesn’t match any DO slot’s currently-paired screenId. Two reasons this happens in practice:
- A TV is reporting before it’s been paired (fresh install / re-pair gap)
- A TV’s pairing was revoked but it hasn’t refreshed yet
Each orphan is tagged verified or unverified. Verified = the screen has more than one history event in tv-health/history/<screenId>.json (i.e., this isn’t a single drive-by ping). Unverified orphans get an extra confirmation in the UI so the operator doesn’t accidentally claim a malformed scanner test as a real TV.
Operators can:
- Claim the orphan → assigns it to a slot via
POST /api/admin/tv-health/orphan/claim. Writes aclaim:<screenId>record in the DO. The TV picks this up on its next heartbeat (or via auto-adoption — see below) and writes the assigned token to localStorage automatically. - Cancel a pending claim →
POST /api/admin/tv-health/orphan/claim/cancel. The DO deletes the claim record before the TV consumes it. - Delete the orphan →
POST /api/admin/tv-health/orphan/delete. Hard-deletes the R2 heartbeat. If the TV is alive it’ll reappear on its next ping. Useful for purging stale dev TVs.
sequenceDiagram participant TV as TV browser (unpaired) participant W as Worker participant DO as TV_PAIRING DO participant OP as Operator (/admin/tv-health) TV->>W: POST /api/tv-health<br/>(no token, screenId only) W->>W: Persist as orphan record in R2 OP->>W: GET /api/admin/tv/dashboard W-->>OP: orphans[] includes screenId OP->>W: POST /api/admin/tv-health/orphan/claim<br/>{ screenId, slotLabel: "Bar Right" } W->>DO: /createClaim DO-->>W: ok TV->>W: POST /api/tv-health (next tick) W->>DO: /consumeClaim?screenId=... DO-->>W: { token, slotLabel } — claim deleted W-->>TV: { adoptedAs: "Bar Right", token: "..." } TV->>TV: Save pair token,<br/>then reload to /tv/
The TV-side adoption path lives in public/tv/script.js (search for AUTO_MIGRATE / adoptedAs); the operator never has to touch the TV physically.
Sources: worker.js (orphan endpoints around line 520, DO consumeClaim around line 1100), public/tv/script.js (~line 2489), tests/tv-orphan-claim.test.js, tests/tv-orphan-delete.test.js
Tile detail (rich metrics)
Clicking a slot tile opens the tile-detail panel — inline below the floor plan on desktop, a bottom-sheet on mobile (<=768px). It renders the full heartbeat payload organized into sections:
| Section | Fields |
|---|---|
| Slot | displayName, pairing token snippet, pairedAt, lastPairChangedAt |
| Heartbeat | receivedAt, screenId, ip, clientIpCountry, userAgent, sessionId, event |
| Build | buildId, remoteBuildId, pendingBuildReloadId, buildPollStatus, buildPollDurationMs |
| Display | viewport, screenResolution, devicePixelRatio |
| Performance | fcpMs, navDomContentLoadedMs, navLoadMs, eventLoopLagMs, memoryUsedMb, memoryTotalMb |
| Content | slideCount, activeSlideIndex, rightRailSlideCount, rightRailActiveSlideIndex, loadedImageCount, brokenImageCount |
| Connection | visibilityState, onLine, connectionType, connectionDownlink, connectionRtt |
| Errors | lastJsError, lastJsErrorAt, lastReloadReason |
Severity colors apply on a per-field basis: e.g. fcpMs > 3000 paints red, memoryUsedMb / memoryTotalMb > 90% paints amber. Thresholds live next to the rendering code in renderTileDetail().
Sources: worker.js (renderTileDetail ~line 5230)
Pi status card
The dashboard surfaces a separate card for the pub Pi’s self-reported system health. Source: POST /api/pi-status (Bearer NOW_PLAYING_INGEST_TOKEN) writes a single pi:status record to DJ_FLASH KV with a 10-minute TTL. The reporter script polls every 30s.
Payload (sanitised + bounded by handlePiStatusIngest):
{
"hostname": "djpi-01",
"ipAddress": "192.0.2.42",
"uptimeSeconds": 86400,
"kernel": "Linux 6.6.20-v8+",
"distro": "Debian GNU/Linux 12",
"cpu": { "cores": 4, "percent": 18.5, "load1": 0.72, "temperatureC": 54.3 },
"memory": { "totalMb": 7800, "usedMb": 1900, "percent": 24.4 },
"disk": { "totalGb": 58.4, "usedGb": 12.1, "percent": 20.7 },
"scripts": { "pi-capture": "running", "pi-status-reporter": "running" },
"transcodeBacklog": 0
}The card is hidden when piStatus is null (no recent report) — installs without a Pi reporter don’t see a permanent red “Pi offline” tile. When fresh, summary line reads Pi · djpi-01 · last seen 30s ago · CPU 18% · Mem 24% and expands to per-metric tiles with severity coloring.
See pi-status-reporter for the deploy recipe.
Sources: worker.js (handlePiStatusIngest, readPiStatus ~line 10770), pi/pi-status-reporter/main.py, tests/pi-status.test.js
Platform status card
The “Platform” card answers “is the Worker healthy?” with three classes of data:
- Build —
TV_BUILD_IDenv var (Wrangler injects per-deploy) - CF edge —
request.cf.colo,country,city,tlsVersion,httpProtocolfor the admin’s connection - Binding probes — cheap reachability checks against each KV/R2 binding:
| Binding | Type | Probe |
|---|---|---|
DJ_FLASH | KV | get("__probe__") |
LYRICS | KV | get("__probe__") |
DJ_MODE | KV | get("__probe__") |
IMAGE_MIRROR_BUCKET | R2 | list({ limit: 1 }) |
DJ_VISUALS | R2 | list({ limit: 1 }) |
Each probe records its own latency. Failures don’t break the dashboard — the probe row shows ✗ <error> and the rest of the dashboard renders normally. Failure latency thresholds: >250ms amber, >500ms or failed → red.
Sources: worker.js (probePlatform, probeBinding ~line 1782), tests/platform-meta.test.js
Admin surfaces
| Path | Purpose |
|---|---|
/admin | Hub — at-a-glance status cards for TV health + DJ mode |
/admin/tv-health | Primary dashboard — floor plan, pair form, orphan strip, Pi + Platform cards |
/admin/tv-health/pair?code=NNNNNN | QR deep-link target on phones — pre-fills the pair code |
/admin/tv-health/raw | Debug — pretty-printed dump of raw R2 heartbeat records (paginated, ?limit=N&before=ISO) |
/admin/slots | CRUD — add/rename/delete slots; edit displayName; revoke pairings; pause/resume health warnings for parked devices |
/admin/dj-mode | DJ-mode controls — see photo-flash |
The shared admin shell paints a status badge (green / amber / red dot) next to TVs, Slots, and Overview in the top nav, derived from the worst slot state in the same dashboard endpoint. The shell uses the operator’s verified Access session.
Sources: worker.js (renderAdminShell, ADMIN_NAV_ITEMS ~line 3220), tests/admin-shell.test.js, tests/admin-mobile.test.js
Auth model
The Worker verifies the Cloudflare Access JWT, reads the operator’s row in pub_admin_roles, and checks the required page or API permission:
- TV health requires
tv.view. - Slot management requires
tv.slots. - Unknown new admin APIs default to full-administrator access.
PUB_ADMIN_TOKENremains a root path for automation and recovery; it is not embedded in admin HTML./api/tv-health(TV → worker): validates the per-TVpairingTokenagainst the DO/api/pi-status,/api/now-playing/ingest(Pi → worker): BearerNOW_PLAYING_INGEST_TOKEN
The raw heartbeat page is full-admin only.
Sources: worker/lib/admin-auth.js, worker/lib/admin-permissions.js, worker.js
Configuration
Cloudflare bindings (declared in wrangler.toml):
| Binding | Type | Purpose |
|---|---|---|
TV_PAIRING | Durable Object | Slot identity, codes, claims, transitions |
IMAGE_MIRROR_BUCKET | R2 | TV heartbeat records under tv-health/screens/ and tv-health/history/ |
DJ_FLASH | KV | pi:status (10m TTL) and flash queue / pending moderation |
LYRICS | KV | Lyrics cache + tombstones (powers the DJ-mode lyrics overlay) |
DJ_MODE | KV | DJ mode settings + queue |
DJ_VISUALS | R2 | Patron / DJ uploaded crowd photos & videos |
Environment / secrets:
| Var | Where used | Notes |
|---|---|---|
TV_BUILD_ID | Surfaced as cfMeta.buildId on the dashboard | Wrangler injects per deploy (or commit-sha format) |
PUB_ADMIN_TOKEN | Root admin token | Automation and recovery only; not embedded in browser pages |
NOW_PLAYING_INGEST_TOKEN | Pi auth — shared by pi-capture, pi-status-reporter, and the Pi transcoder | Single secret = no extra key rotation surface |
The slot thresholds are code constants in worker/lib/tv-slot-state.js (TV_STALE_MS = 5 min, TV_MISSING_MS = 30 min), not environment variables.
Sources: wrangler.toml
Operational notes
When the Pi sends show to a TV
Samsung TVs can accept a WebSocket browser-launch command without opening the browser. The Pi relay no longer treats a successful send as proof that the action happened:
- Send the WebSocket launch.
- Wait briefly, then query the TV’s REST application-status endpoint for the modern browser app id and its legacy alias.
- If the browser is confirmed absent or the WebSocket call failed, try the REST launch endpoint.
- Record the observed stage instead of inferring success from transport alone.
| Stage | Meaning |
|---|---|
ws_launch_ok | WebSocket launch sent and REST confirmed the browser is running |
ws_launch_unverified | Command sent, but the TV would not report application status |
rest_launch_ok | REST fallback opened the browser; this path cannot set a URL |
ws_error | WebSocket failed and the fallback did not recover |
launch_failed | The WebSocket path did nothing and the REST fallback failed |
This distinction keeps TV-control telemetry usable when deciding whether the venue needs a hardware IR fallback.
Sources: pi/pi_capture/tvctl.py, pi/tests/test_tvctl.py at Pub commit fb13cdf
When a TV slot stays red
- Open the tile in the dashboard —
lastJsErrorandlastReloadReasonusually tell the story - If the TV hasn’t pinged at all, check the Pi:
systemctl status pi-capture pi-status-reporter(a hung Pi can also block TV updates because it shares the AV LAN switch) - If multiple TVs went red simultaneously, check the Platform card — a failing KV/R2 probe means the Worker can’t serve the dashboard payload either
- If only one TV is red, try
/admin/tv-health/raw— search for the screenId in the dump to confirm the R2 record exists
When a TV becomes an orphan after a re-pair
Expected. The old token is revoked but the TV browser is still presenting it on every heartbeat. Two recoveries:
- Operator-driven: claim the orphan from the dashboard — assigns it to the right slot, TV adopts automatically on next ping
- TV-driven: refresh the TV browser —
/tv/sees the 401, clears localStorage, redirects to/tv/pairfor a fresh code
The “Cancel pending claim” button on the offline-claims strip exists for the case where you claimed wrong and need to undo before the TV picks it up.
When the Pi reporter goes silent
The card stays put for 10 minutes (KV TTL), then disappears entirely. Check systemctl status pi-status-reporter on the Pi. The reporter is non-critical — pi-capture (the song-ID service) is independent and keeps running.
Related
- pi-status-reporter — deploy + runbook for the reporter
- photo-flash — DJ mode + patron upload pipeline
- pi-capture-ops — Pi audio capture runbook (separate service from the reporter)
- architecture — top-level routing diagram
- api — API contract reference