@enfinitos/sdk-dooh-renderer
EnfinitOS reference SDK for the DOOH substrate — a governance- aware media-player runtime that runs on a DOOH screen hardware (billboard, transit shelter, mall panel, taxi-top display, elevator screen) and reports playback, surface health, and panel-dimming events to the EnfinitOS rights/policy/audit plane.
This is the SDK an operator integrates in place of a Broadsign player (or alongside one in a mixed estate). Operators happy with Broadsign keep their existing player and EnfinitOS connects through the Broadsign adapter in packages/integrations/broadsign-adapter. Operators wanting tighter EnfinitOS integration — pre-render consent gating, governance proofs, rights-aware fallback content, regulatory dimming audit trails — deploy this SDK.
The SDK builds on @enfinitos/sdk-renderer-core, the substrate-agnostic foundation that every EnfinitOS renderer wraps. The DOOH renderer composes the core's primitives (resolve, grant, event reporter, health reporter, consent client, transport) and adds three DOOH-specific concerns:
- Asset preloading — pre-fetch the panel's next-N-minutes of scheduled content during periods of cheap bandwidth so the panel keeps rendering through cellular blips.
- Surface-health reporting — a DOOH-flavoured health envelope covering decoder frame stats, ambient-light readings, panel temperature, content-cache occupancy, network bearer, and currently-applied dimming.
- Panel-dimming audit — record the moments when the panel dimmed (and why) so regulators in jurisdictions that mandate ambient-light-driven dimming (UK, FR, NL, NSW, several US states) can audit conformance.
Platform-side counterpart. This SDK consumes the platform's existing runtime endpoints (/runtime/resolve,/runtime/grant,/runtime/event-ingest,/runtime/health-ingest) — no new server-side work is required for the SDK to function. The DOOH-flavoured health subsystem map rides under the existingsubsystemsfield onDeviceHealth.
Architecture
┌──────────────────────────┐
│ EnfinitOS Platform │
│ (runtime, rights, │
│ audit, fleet health) │
└────────────┬─────────────┘
│ HTTPS REST
│ (device JWT)
│
┌────────────────────────────────▼─────────────────────────┐
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ EnfinitOSDoohRenderer │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌─────────────────────┐ │ │
│ │ │ renderer-core │ │ SurfaceHealth │ │ │
│ │ │ Client │ │ Collector + │ │ │
│ │ │ (resolve, │ │ Reporter │ │ │
│ │ │ grant, │ │ (decoder, │ │ │
│ │ │ events, │ │ ambient, │ │ │
│ │ │ health, │ │ temp, │ │ │
│ │ │ consent) │ │ cache, │ │ │
│ │ │ │ │ network, │ │ │
│ │ │ │ │ dimming) │ │ │
│ │ └─────────────────┘ └─────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ AssetPreloader (concurrency-bounded │ │ │
│ │ │ pre-fetch + retry + │ │ │
│ │ │ pluggable cache sink) │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└──────────────────┬───────────────────────────────────────┘
│
▼
Panel firmware
(video decoder, ambient-light sensor,
temperature probe, dimming controller,
on-disk cache, display element)
Why this SDK and not just renderer-core?
@enfinitos/sdk-renderer-core ships everything substrate-agnostic. It does NOT prescribe:
- Pre-fetch policy. DOOH panels run on bandwidth-constrained private 4G; pre-fetch is a real cost driver and a real reliability driver. The core couldn't sensibly pick a default.
- Substrate-specific health vocabulary. A mobile SDK's health has different subsystems (ATT consent, view-port visibility) from a DOOH player's (decoder frame stats, ambient light). The core leaves the subsystems map free-form; the DOOH SDK fills it with the right typed fields.
- Panel-dimming audit. This is a regulatory concern that simply doesn't apply to most substrates. Encoding it as a first-class method here makes the audit trail explicit and easy to grep for.
Getting started
Install
pnpm add @enfinitos/sdk-dooh-renderer
(@enfinitos/sdk-renderer-core is a peer dependency.)
Five-minute hello-world
import { EnfinitOSDoohRenderer } from "@enfinitos/sdk-dooh-renderer";
const renderer = new EnfinitOSDoohRenderer({
apiBaseUrl: "https://api.enfinitos.com",
surfaceId: "panel_londonbridge_west",
deviceId: "panel_londonbridge_west", // same as surface here
deviceToken: process.env.ENFINITOS_JWT!,
onAssetReady: (asset) => video.src = asset.assetUrl,
onError: (err) => console.error("dooh", err),
});
await renderer.start();
// 1. Resolve the next slot.
const asset = await renderer.resolveNext({
doohSlot: {
placementType: "transit_shelter",
orientation: "portrait",
aspectRatio: 9 / 16,
estimatedDwellS: 14,
},
});
if (asset) {
await renderer.reportPlayStarted(asset, new Date());
// ... play it ...
await renderer.reportPlayEnded(asset, new Date(), 6000);
}
// 2. Pre-fetch the next hour of scheduled content overnight.
await renderer.preloadAssets([
"ad_42_v3",
"ad_99_v1",
"ad_house_002",
]);
// 3. Ambient light dropped → dim the panel and record it.
await renderer.reportPanelDimming({
level: 0.4,
appliedAt: new Date().toISOString(),
reason: "ambient",
ambientReading: { reading: 8200, unit: "lux", sensorState: "ok" },
});
// 4. Heartbeat health every 30s.
renderer.surfaceHealth.setTemperature({ reading: 42, unit: "C" });
renderer.surfaceHealth.setDecoderCounters({ framesDecoded: 1800, framesDropped: 1 });
const stopHeartbeat = renderer.startHealthHeartbeat(30_000);
API surface
Constructor
new EnfinitOSDoohRenderer({
apiBaseUrl: string; // platform URL
surfaceId: string; // DOOH placement
deviceId?: string; // defaults to surfaceId
deviceToken: string; // platform-issued JWT
onAssetReady?: (asset) => void;
onError?: (err) => void;
cacheSink?: AssetCacheSink; // optional: disk-backed cache
fetcher?: AssetFetcher; // optional: custom byte fetcher
surfaceHealth?: SurfaceHealthCollectorOptions;
preloader?: { concurrency?; maxAttempts?; fetchTimeoutMs?; };
renderer?: { /* renderer-core opts */ };
})
Inherited (from renderer-core)
| Method | Description |
|---|---|
start() / stop() | Open / close the platform session. |
resolveNext(context?) | Resolve the next asset; accepts DOOH-flavoured context. |
grant(assetId, opts?) | Standalone grant (rare; mostly for pre-fetch). |
reportPlayStarted / Ended / Error | Proof-of-play events. |
reportClick / Conversion | Viewer-interaction events. |
reportHealth(state, subsystems?) | Substrate-agnostic health heartbeat. |
startHealthHeartbeat(ms) | Periodic heartbeat (defaults to a DOOH-rich builder). |
drainEvents() | Force a queue drain before sleep. |
DOOH-specific
| Method | Description | |
|---|---|---|
| `preloadAssets(ids \ | resolved[])` | Pre-fetch bytes to the panel cache. |
reportSurfaceHealth(metrics) | Submit a DOOH-flavoured health envelope. | |
reportPanelDimming(level) | Record a dimming event (regulator audit). |
Direct subsystem access
renderer.surfaceHealth // SurfaceHealthCollector — feed sensor readings here
renderer.preloaderRef // AssetPreloader — inspect / drive directly
renderer.rendererCore // EnfinitOSRendererClient — escape hatch
Surface-health vocabulary
The DOOH renderer's surface-health envelope is the substrate-specific specialisation of the renderer-core's DeviceHealth.subsystems map. Every field is optional — panels report what their firmware exposes.
| Subsystem | What it carries |
|---|---|
decoder | codec, frames decoded / dropped / error count this window, last error |
ambientLight | reading (lux / nits), sampledAt, sensorState (ok / drift / offline) |
temperature | reading + unit (°C/°F/K), sampledAt, warnAt |
contentCache | bytes / capacity / count, preload attempts + failures this window |
network | bearer (eth/wifi/cellular), RTT, RSRP dBm, up/downlink kbps |
dimmingLevel | 0..1 currently-applied dimming |
The derived overall state from the SDK's SurfaceHealthCollector:
| Condition | Derived state |
|---|---|
| Decoder drop > 10% | UNHEALTHY |
| Temperature > 85°C (or unit equivalent) | UNHEALTHY |
| Decoder drop > 2% or decoder errors > 0 | DEGRADED |
| Temperature > 70°C | DEGRADED |
| Ambient-light sensor offline or drift | DEGRADED |
| Cache fill < 10% | DEGRADED |
| Cellular RSRP ≤ -110 dBm | DEGRADED |
| otherwise | OK |
Application code can override this with reporter.reportNow({ overrideState: "OFFLINE" }) when it knows something the collector can't infer (e.g. the management plane just told it to go to a maintenance window).
Asset preloader pattern
The preloader is a small state-machine over a substrate-pluggable cache backend:
enqueue() ─► [pending] ──► [fetching] ──► [ready]
├──────────► [failed] (maxAttempts hit)
└──────────► [pending] (retry w/ backoff)
pruneExpired() ──► [ready] ──► [expired]
Default cache sink: InMemoryCacheSink (256 MiB). Production deployments bring their own (a disk-backed LRU, a webOS app cache, a Tizen JS cache). Implement AssetCacheSink:
class DiskBackedSink implements AssetCacheSink {
async store({ assetId, bytes, ... }) { /* write to disk */ }
async has(assetId) { /* check disk */ }
async evict(assetId) { /* unlink */ }
async stats() { return { cacheBytes, cacheCapacityBytes, cachedAssetCount }; }
}
The preloader supports:
- Concurrency cap (default 4). Bandwidth-friendly.
- Per-asset exponential backoff with attempt cap (default 3).
- Side-band cache hit checks: if the sink already has the asset, no fetch happens.
- Side-band cache stats exposed to the SRE dashboard via the surface-health collector's
contentCachesubsystem.
Panel-dimming audit
reportPanelDimming(level) records a panel-dimming event for regulatory audit. The reported level is also remembered by the surface-health collector so the next heartbeat reflects the current applied dimming. The level is sent as an interaction event on a synthetic "_panel" asset so the platform-side audit log keeps panel-level events distinct from viewer-driven events on actual content.
The full report shape:
{
level: 0.4, // 0..1
appliedAt: "2026-05-13T19:24:00.000Z",
reason: "ambient" | "schedule" | "operator" | "regulatory",
correlationId?: "rule_uk_billboards_v3", // optional
ambientReading?: { reading: 8200, unit: "lux", sensorState: "ok" },
}
Operator integration patterns
Mode 1: full EnfinitOS player
Panel firmware vendor builds a JS host (Tizen / WebOS / Android TV / custom Linux + Electron / Chromium kiosk) and includes this SDK verbatim. The SDK drives resolve, render, report, health-report loop. Operator's existing CMS schedules feed the SDK via preloadAssets() or via the platform's day-part scheduling.
Mode 2: hybrid with Broadsign player
Operator keeps Broadsign's player for legacy estate and runs this SDK on the new estate. Both players report against EnfinitOS for audit and rights enforcement; reconciliation happens server-side via the Broadsign adapter in packages/integrations/broadsign-adapter.
Mode 3: adapter mode (server-side)
Operator can't change their panel firmware but wants EnfinitOS governance. The Broadsign / VIOOH / Hivestack / PlaceExchange adapters in packages/integrations use this SDK's contract types to mirror the same wire shape against EnfinitOS — the panel firmware is unchanged.
Sample integration — minimal viable panel
import {
EnfinitOSDoohRenderer,
InMemoryCacheSink,
} from "@enfinitos/sdk-dooh-renderer";
async function main() {
const renderer = new EnfinitOSDoohRenderer({
apiBaseUrl: "https://api.enfinitos.com",
surfaceId: "panel_001",
deviceToken: process.env.ENFINITOS_JWT!,
onAssetReady: (a) => video.src = a.assetUrl,
onError: console.error,
});
await renderer.start();
// Slot loop.
while (true) {
const asset = await renderer.resolveNext({
doohSlot: { placementType: "outdoor_billboard", orientation: "landscape" },
});
if (asset) {
const start = new Date();
await renderer.reportPlayStarted(asset, start);
await playUntilEnd(video, asset);
await renderer.reportPlayEnded(asset, new Date(), Date.now() - +start);
}
await sleep(asset?.durationMs ?? 6_000);
}
}
Tests
pnpm test
The vitest suite covers three areas:
| Module | Tests |
|---|---|
surfaceHealth.ts | derivation policy (OK/DEGRADED/UNHEALTHY ladder), counter resets, ambient-light flagging, temperature unit normalisation, reporter integration with the renderer-core. |
assetPreloader.ts | state machine transitions, retry policy, in-memory sink eviction, custom sink contract, expiry pruning. |
doohRenderer.ts | argument validation, lifecycle delegation, panel-dimming validation + audit-event emission, preload bytes-to-cache integration, resolve-context folding. |
What's SDK-side vs platform-side
| Concern | This SDK | Platform |
|---|---|---|
| DOOH-flavoured health envelope | ✅ shape + collection | ✅ accepts existing DeviceHealth.subsystems |
| Asset preloader | ✅ | — (no platform endpoint, panel-local) |
| Panel-dimming audit event | ✅ rides existing interaction-event ingest | ✅ existing endpoint |
| Pluggable cache sink | ✅ contract | (panel-firmware-specific impl) |
| Substrate-agnostic primitives | (inherits from renderer-core) | ✅ existing runtime plane |
See also
packages/sdks/renderer-core/README.md— substrate-agnostic foundation.packages/integrations/broadsign-adapter/— mode-3 adapter for Broadsign-controlled fleets.docs/launch/substrate-readiness-matrix.md— DOOH substrate readiness.packages/sdks/streaming-player/— sibling SDK for streaming substrates (HLS / DASH / WebRTC).packages/sdks/ctv-app/— sibling SDK for CTV (Roku / Tizen / WebOS / Apple TV).