• TV slots, the pairing flow (operator code → TV adoption), heartbeat reporting, the orphan-claim queue, and the admin dashboard at /admin/tv-health that 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:

RecordDO storage keyPurpose
Slotslot:<label>{ label, displayName, currentToken, pairedAt, lastPairChangedAt, createdAt, healthMonitoringMuted } — identity, pairing, and whether warning counts/email ignore an intentionally parked slot
Pair codecode:<6-digit>Short-lived { slotLabel, screenId, expiresAt, status } while a TV’s pair flow is open
Claimclaim:<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:

StateTriggerVisual
freshValid heartbeat less than 5 minutes oldGreen
staleValid heartbeat from 5 minutes to less than 30 minutes oldAmber
missingPaired slot with no valid heartbeat, or a heartbeat at least 30 minutes oldRed
unpairedSlot exists with no currentTokenGrey

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:

  1. Validates the token against the DO (rejects unknown tokens unless AUTO_MIGRATE=true — see auto-pair below)
  2. Stores the payload as tv-health/screens/<screenId>.json in IMAGE_MIRROR_BUCKET R2 (one file per TV, overwritten each ping)
  3. Appends a small event to tv-health/history/<screenId>.json for 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:

  1. A TV is reporting before it’s been paired (fresh install / re-pair gap)
  2. 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 a claim:<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 claimPOST /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:

SectionFields
SlotdisplayName, pairing token snippet, pairedAt, lastPairChangedAt
HeartbeatreceivedAt, screenId, ip, clientIpCountry, userAgent, sessionId, event
BuildbuildId, remoteBuildId, pendingBuildReloadId, buildPollStatus, buildPollDurationMs
Displayviewport, screenResolution, devicePixelRatio
PerformancefcpMs, navDomContentLoadedMs, navLoadMs, eventLoopLagMs, memoryUsedMb, memoryTotalMb
ContentslideCount, activeSlideIndex, rightRailSlideCount, rightRailActiveSlideIndex, loadedImageCount, brokenImageCount
ConnectionvisibilityState, onLine, connectionType, connectionDownlink, connectionRtt
ErrorslastJsError, 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:

  1. BuildTV_BUILD_ID env var (Wrangler injects per-deploy)
  2. CF edgerequest.cf.colo, country, city, tlsVersion, httpProtocol for the admin’s connection
  3. Binding probes — cheap reachability checks against each KV/R2 binding:
BindingTypeProbe
DJ_FLASHKVget("__probe__")
LYRICSKVget("__probe__")
DJ_MODEKVget("__probe__")
IMAGE_MIRROR_BUCKETR2list({ limit: 1 })
DJ_VISUALSR2list({ 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

PathPurpose
/adminHub — at-a-glance status cards for TV health + DJ mode
/admin/tv-healthPrimary dashboard — floor plan, pair form, orphan strip, Pi + Platform cards
/admin/tv-health/pair?code=NNNNNNQR deep-link target on phones — pre-fills the pair code
/admin/tv-health/rawDebug — pretty-printed dump of raw R2 heartbeat records (paginated, ?limit=N&before=ISO)
/admin/slotsCRUD — add/rename/delete slots; edit displayName; revoke pairings; pause/resume health warnings for parked devices
/admin/dj-modeDJ-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_TOKEN remains a root path for automation and recovery; it is not embedded in admin HTML.
  • /api/tv-health (TV → worker): validates the per-TV pairingToken against the DO
  • /api/pi-status, /api/now-playing/ingest (Pi → worker): Bearer NOW_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):

BindingTypePurpose
TV_PAIRINGDurable ObjectSlot identity, codes, claims, transitions
IMAGE_MIRROR_BUCKETR2TV heartbeat records under tv-health/screens/ and tv-health/history/
DJ_FLASHKVpi:status (10m TTL) and flash queue / pending moderation
LYRICSKVLyrics cache + tombstones (powers the DJ-mode lyrics overlay)
DJ_MODEKVDJ mode settings + queue
DJ_VISUALSR2Patron / DJ uploaded crowd photos & videos

Environment / secrets:

VarWhere usedNotes
TV_BUILD_IDSurfaced as cfMeta.buildId on the dashboardWrangler injects per deploy (or commit-sha format)
PUB_ADMIN_TOKENRoot admin tokenAutomation and recovery only; not embedded in browser pages
NOW_PLAYING_INGEST_TOKENPi auth — shared by pi-capture, pi-status-reporter, and the Pi transcoderSingle 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:

  1. Send the WebSocket launch.
  2. Wait briefly, then query the TV’s REST application-status endpoint for the modern browser app id and its legacy alias.
  3. If the browser is confirmed absent or the WebSocket call failed, try the REST launch endpoint.
  4. Record the observed stage instead of inferring success from transport alone.
StageMeaning
ws_launch_okWebSocket launch sent and REST confirmed the browser is running
ws_launch_unverifiedCommand sent, but the TV would not report application status
rest_launch_okREST fallback opened the browser; this path cannot set a URL
ws_errorWebSocket failed and the fallback did not recover
launch_failedThe 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

  1. Open the tile in the dashboard — lastJsError and lastReloadReason usually tell the story
  2. 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)
  3. 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
  4. 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:

  1. Operator-driven: claim the orphan from the dashboard — assigns it to the right slot, TV adopts automatically on next ping
  2. TV-driven: refresh the TV browser — /tv/ sees the 401, clears localStorage, redirects to /tv/pair for 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.