Compare commits

...

106 commits
v1.8.3 ... main

Author SHA1 Message Date
ScreenTinker d9fb914b9e chore(release): v1.9.2-beta1
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-06-27 19:59:34 -05:00
ScreenTinker ce78d0dde4 docs(#142): 1.9.2-beta1 changelog + device_status_log VACUUM maintenance note
Documents the #142 changes and tells operators with an already-bloated
device_status_log to reclaim space with a one-time manual VACUUM in a maintenance
window (retention now bounds further growth). Explains why auto-VACUUM is not
enabled. New doc: docs/maintenance-device-status-log.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:59:17 -05:00
ScreenTinker f206537fed Merge #142 (reconnect-storm hardening) into main for 1.9.2-beta1
Brings the full #142 stack onto main on top of the 1.9.1 stable cut:
- device_status_log index + de-dupe
- event-loop lag telemetry (bounded)
- load-aware per-device reconnect throttle (the outage fix)
- global device_status_log retention sweep (STATUS_LOG_RETENTION_DAYS)
- content-ack dedup
- provisioning-row cleanup window 365d -> 24h
2026-06-27 19:56:46 -05:00
ScreenTinker 139d7d09fa fix(#142): provisioning-row cleanup window 365d -> 24h (matches its own comment)
services/heartbeat.js deleted unclaimed provisioning devices with
created_at < now - (365 * 86400) — a YEAR — while its own comment said "older
than 24 hours". So socket-register pairing junk lingered ~365x longer than
intended. Change the window to 24 * 3600 to match the comment.

Correctness fix only — does NOT touch the pre-auth register path or add a rate
limiter (that pre-auth hardening is a separate security issue, out of this cut).

Extracted the sweep into pruneProvisioningDevices() (still in heartbeat.js, called
from the same interval) so it is unit-testable. Test asserts a >24h unclaimed
provisioning row is swept while a <24h row, an imported row (user_id set), and a
non-provisioning row are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:56:32 -05:00
ScreenTinker 852219cb45 chore(release): v1.9.1 2026-06-27 19:50:09 -05:00
ScreenTinker 15448d1c5d fix(#142): dedup repeated content-ack reports (secondary load)
device:content-ack logged + emitted every message, so a device repeatedly
reporting the same "content <id>: ready" (observed from an older app version)
added avoidable load per message.

- Suppress identical (device_id, content_id, status) reports within
  config.contentAckDedupMs (default 10s), modeled on the lastPlayLogAt throttle.
  A status change has a different key and passes immediately; a fresh report after
  the window passes too. In-memory, resets on restart. The handler does no DB
  writes, so this is purely shedding redundant log+emit work.

test: integration over a real authenticated device socket — a burst of identical
"ready" collapses to one log/emit, a "ready" after the window passes, and a status
change is never deduped. Unique PORT (3984).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:35:04 -05:00
ScreenTinker 29a8896aa8 fix(#142): global device_status_log retention sweep + STATUS_LOG_RETENTION_DAYS
The per-device insert-time prune (deviceSocket.js) only ever touches a device
that is actively inserting, so it misses two paths: removed/idle devices whose
rows linger forever, and heartbeat.js's offline_timeout insert that bypasses
logDeviceStatus entirely. The reporter's 1.2M-row bloat accumulated UNDER a 7-day
per-device prune for exactly this reason.

- pruneStatusLog() (db/database.js): a GLOBAL time-range sweep across ALL devices,
  modeled on the play_logs prune. Run once on startup (recovers a bloated table
  right after deploy) and on the heartbeat interval (services/heartbeat.js).
- STATUS_LOG_RETENTION_DAYS env, default 3 (lower than the old hardcoded 7d; the
  dashboard only shows a 24h uptime window, so 2-3d is ample for diagnostics).
- Deliberately NO per-device row cap: Step 3's throttle already bounds how fast a
  storming device can generate status rows, so a cap would add sweep complexity
  for little gain (noted for later if needed).
- NO VACUUM / auto_vacuum here (kept off the hot path); space reclaim is left as a
  separate decision (see report).

test: deterministic in-process unit test proves the sweep deletes over-retention
rows across all devices — including a device absent from the devices table and an
offline_timeout row — while keeping recent rows; idempotent on an empty table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:34:19 -05:00
ScreenTinker 101f086204 fix(#142): load-aware per-device reconnect throttle (the outage fix)
Gates genuine reconnects PER DEVICE before the heavy register work (DB writes +
playlist build) runs, so a single flapping device can no longer saturate the
event loop and take down the server.

- Actuator is per-device, keyed on device_id (modeled on lastPlayLogAt). A device
  is flagged only when it exceeds reconnectBaseMax genuine reconnects per window.
  Same-socket playlist refreshes (isPlaylistRefresh) are exempt.
- Load-awareness is BANDED (normal/elevated/critical from the step-2 lag signal),
  not a continuous controller. The band only MULTIPLIES an already-flagged
  device's backoff; global lag never gates a healthy device.
- Hysteresis: escalate immediately while storming (tighten fast); decay one level
  per reconnectReleaseMs of calm (release slow).
- HARD CEILING per device, independent of band and warm-up — a slow-ramp attacker
  can't train through it.
- COLD START: for reconnectWarmupMs after boot, force the normal band and apply
  only the hard ceiling, so a full-fleet reconnect after a deploy doesn't throttle
  healthy screens. State is in-memory, resets on restart.
- Observability: every throttle engagement logs device, band, observed vs allowed
  rate, and backoff. Throttled device gets device:throttled + a deferred disconnect.

Tests (api.test.js style):
- unit: healthy-never-throttled, storm-throttled-with-growing-backoff, band
  multiplies backoff, hard-ceiling-even-in-warmup, warm-up leniency, neighbor
  isolation, slow release.
- integration GATE (the required one): full-fleet reconnect right after restart
  throttles NO healthy device; a single device storming IS throttled; a neighbor
  stays unaffected while another storms.
- also fixes pre-existing test PORT collisions (my new integration files clashed
  with totp.test.js:3979 and totp-keyrotation.test.js:3980 -> moved to 3982/3983);
  full suite now green serially AND in parallel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:18:00 -05:00
ScreenTinker ed3cf72b82 feat(#142): event-loop lag telemetry (perf_hooks) + bounded storage
Continuously samples event-loop delay via perf_hooks.monitorEventLoopDelay()
(C++-backed histogram; cheap). Each window persists mean/p50/p99/max to a new
event_loop_lag table and recomputes a coarse load band (normal/elevated/critical)
from the window p99. Standalone value: current lag is exposed on /api/status and
band changes are logged, so site lag is diagnosable independent of throttling.

The band feeds the #142 reconnect throttle (next commit) but ships first as its
own subsystem.

- event_loop_lag is bounded from day one: indexed on sampled_at + scheduled prune
  (LAG_TELEMETRY_RETENTION_DAYS, small default) modeled on the play_logs prune.
  Deliberately NOT another unbounded-growth table.
- Band transitions are asymmetric: jump up immediately (tighten fast), release one
  level at a time after N calm samples below a deadband (release slow, no flap).
  Pure nextBand() function, unit-tested deterministically.
- config: LAG_SAMPLE_INTERVAL_MS, LAG_RESOLUTION_MS, LAG_TELEMETRY_RETENTION_DAYS,
  LAG_PRUNE_INTERVAL_MS, LAG_ELEVATED_MS, LAG_CRITICAL_MS, LAG_RELEASE_SAMPLES.
- tests: band-transition unit tests; integration proves sampling persists, stays
  bounded under the prune, and surfaces on /api/status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:01:08 -05:00
ScreenTinker d90cfb3986 fix(#142): index device_status_log + de-dupe its CREATE TABLE
The dashboard uptime query (WHERE device_id=? AND timestamp>?) and the
per-device retention prune (WHERE device_id=? AND timestamp<?) were both full
table scans. At 1M+ rows (the outage report) this was the dashboard-degradation
cause that persisted even after the reconnect storm stopped.

- schema.sql: add idx_device_status_log_device_ts(device_id, timestamp); both
  queries now SEARCH ... USING INDEX instead of SCAN (verified via EXPLAIN).
- database.js: same index as a migration for existing DBs (idempotent).
- schema.sql defined device_status_log twice; drop the duplicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:54:57 -05:00
ScreenTinker f96b65576f chore(release): guard bump-version.sh against a diverged origin/main
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
Add a pre-push fast-forward check: fetch origin/main and abort if it has commits not in local HEAD, BEFORE the annotated tag is created. Prevents the beta9 incident where origin/main had advanced by one commit so 'git push origin main' was rejected, but the tag pushed anyway and fired release.yml from a commit not on main. Best-effort fetch — warns and proceeds when offline (the push stays the backstop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:26:23 -05:00
ScreenTinker ed164647b8 Merge origin/main (Update SECURITY.md) into beta9 cut 2026-06-25 12:16:47 -05:00
ScreenTinker ae018b8eea chore(release): v1.9.1-beta9 2026-06-25 12:06:44 -05:00
ScreenTinker 071d7cc9c3 fix(server): persist per-item mute into the published snapshot (#129)
A mute toggle wrote the draft playlist_items + emitted a live device:mute-changed but only markDraft()'d — it never updated playlists.published_snapshot, the copy the device actually plays. So the device's item.muted stayed 0 and every loop/reload re-applied full volume: dashboard icon red but audio kept playing (Android; web's native <video> loop masked it). emitMuteChanged now surgically patches the matching item's muted (0/1) inside the published_snapshot and re-pushes the playlist, so loops re-apply the correct flag. Surgical patch (not publishPlaylist) so a mute toggle can't prematurely publish other draft edits or flip publish state. Adds a regression test that fails without the patch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:06:29 -05:00
screentinker 1e1ed7e29a
Update SECURITY.md
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-24 12:09:25 -05:00
ScreenTinker 36c4bf523f chore(release): v1.9.1-beta8 2026-06-24 11:43:31 -05:00
ScreenTinker 16c381254b fix(android): lower minSdk 26 -> 24 to support Android 7.0/7.1 panels (#141)
Covers API 24 (7.0) + 25 (7.1.2); all 26+ APIs were already guarded with graceful else branches; no dependency bumps. Validated on API 24 + 25 emulators: install, foreground service, #139 OTA verify on the legacy GET_SIGNATURES path (incl. tampered-refuse), EncryptedSharedPreferences, and playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:38:56 -05:00
Christopher Cookman 01e5b10f53
feat(setup): Debian 13 player/server install script (#137)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Community contribution from @ChrisChrome (tested on Debian 13 headless). Adds scripts/debian-13-setup.sh — server/player/both modes, systemd units, kiosk autologin, and management scripts (status/update/logs) — modeled on the Raspberry Pi setup. Also fixes Chromium fullscreen by detecting screen resolution at runtime (replacing --start-fullscreen), applied to both the Debian and Pi scripts, plus a README entry.

Maintainer review fix: the kiosk wait-loop now polls /api/status (the server's real readiness endpoint) instead of the non-existent /api/health, which had been silently burning the ~120s timeout on every all-in-one boot (bug inherited from the Pi script, fixed in both).
2026-06-23 23:47:22 -05:00
ScreenTinker 9c990ff91f chore(release): v1.9.1-beta7
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-23 23:23:00 -05:00
ScreenTinker a6fe849c67 Merge fix/ota-redownload-loop (#140): stop OTA re-download loop on devices that can't silently install (#139) 2026-06-23 23:22:29 -05:00
ScreenTinker 0c0a8dd68a fix(ota): surface stuck OTA on dashboard + read APK signer correctly on API 28/29 (#139)
Follow-up to the cache/backoff loop fix (aa23cf0): make a device that can't
self-install visible to operators, and fix the signature-verify bug that kept the
whole #139 fix from engaging on the actual Fire OS target.

Dashboard surface (Phase 2):
- devices gains ota_status / ota_target_version / ota_attempts / ota_updated_at
  via the idempotent ALTER TABLE ADD COLUMN migration (non-destructive,
  default-backfilled, idempotent on re-run).
- The device reports ota_status (OtaThrottle.statusFor -> none | pending |
  manual_update_required) in device_info; the server persists it on register
  (the reconnect backstop). devices d.* already surfaces it to the dashboard.
- Dashboard shows a non-blocking amber badge when manual_update_required
  ("Update available (vX) - install failed N times, manual update required");
  i18n key in en.js (non-en inherits via the en fallback). Server suite +1 test.

Event-driven status (Option B):
- New device:ota-status WS message, emitted on STATE TRANSITIONS only
  (enter-backoff -> manual_update_required, clear -> none), so the badge updates
  promptly without waiting for a reconnect and without per-poll/heartbeat chatter.
  Server handler persists the same fields; an unknown/forged device_id is a safe
  no-op. The register-path persist stays as the reconnect backstop.

Signature-verify fix (the critical piece):
verifyApkSignature read the downloaded APK's signer via
getPackageArchiveInfo(GET_SIGNING_CERTIFICATES).signingInfo, but that field is
null for ARCHIVE files on API 28/29 (populated only from API 30). On Fire OS 8
(Android 9 / API 28) - the actual deployment target - this returned 0 certs from
a correctly-signed APK, so every OTA was refused as "tampered," the cache was
deleted, and the full APK re-downloaded every check cycle. This was the real
cause of the #139 re-download loop, NOT a silent-install failure: the cache and
backoff added in this branch sit behind this verify gate and never engaged on
the target.

Fix: below API 30, read the archive's signer via the legacy GET_SIGNATURES +
.signatures (its v1/JAR cert, which IS populated on 28/29). Keep
GET_SIGNING_CERTIFICATES + signingInfo for API >= 30 and for the installed-app
read (which works on 28+). The archive's signer is still extracted and compared
to the installed app's signer; a mismatch or zero-cert APK is still rejected.
This reads the cert correctly on old APIs - it does not weaken verification.

Verified on emulators:
- API 28: verify now passes for a legit APK (was: 0 certs, refused). Full backoff
  then engages - 8.5MB pulled once, cache-hit on retries, backoff after 3,
  manual_update_required emitted once; clears on successful update.
- API 28 negative: a re-signed (different-key) APK is still refused on cert
  MISMATCH - no hole opened.
- API 30: unchanged path still passes (no regression).
- server suite 173/173, OtaThrottleTest 7/7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:49:01 -05:00
ScreenTinker aa23cf02dd fix(ota): stop OTA re-download loop on devices that cannot silently install (#139)
Devices that download an OTA APK but cannot silently install it (Fire TV: no
device-owner path) re-downloaded the full APK every check cycle indefinitely -
install never completes, version never advances, next check re-triggers.

Client (UpdateChecker.kt, ServerConfig.kt, OtaThrottle.kt):
- Reuse a cached, signature-verified APK instead of re-downloading every cycle;
  delete leftover invalid files; keep the verified APK on disk as the
  manual-install artifact.
- Persisted per-version attempt budget (EncryptedSharedPreferences) so it
  survives the Fire OS app restarts that drive the loop. An attempt is counted
  only when an install is launched - a download/verify failure does not consume
  the budget, so a transient network problem cannot park a healthy device in
  backoff. After 3 failed installs, back off to one retry per 24h.
- Clear OTA state and caches when a check returns update_available=false while
  state is pending (app relaunched as the new version).
- Report OTA status to the dashboard via device:log (tag ota) on state
  transitions only (enter-backoff, clear) to avoid flooding the channel.
- Extract throttle decision logic into a pure OtaThrottle object (no Android
  deps) with JUnit coverage (OtaThrottleTest) for the state transitions.

Server (server.js):
- Reword /download/apk log from "OTA update in progress" to "APK served" and
  rate-limit to once per IP / 10 min so a looping device cannot flood the log.

Note: client-cooperative fix - prevents the loop in cohorts running this APK.
Currently-stuck beta4 devices still require a one-time manual update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:53:55 -05:00
ScreenTinker a9cf8747cb chore(release): v1.9.1-beta6
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Version bump for the beta6 strobe artifact: VERSION, server package + lock, and
the Android client (versionName 1.9.1-beta6, versionCode 26 so OTA sees it as an
upgrade over beta5/25). Also removes two leftover untracked PiP test scratch files
(frontend/alert-overlay.html, frontend/overlay.js) from a prior session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:31:33 -05:00
ScreenTinker 99cad902f2 Merge fix/multizone-screenshot-composite (#138): composite multi-zone layouts in screenshot capture
Web player captureAndSend() now composites each zone from its real rendered
geometry (object-fit honoured, aspect-correct), draws CORS-safe labelled
placeholders for cross-origin/iframe zones, and splits rendering into a
socket-free renderCaptureCanvas(). One full-quality path serves both the
on-demand screenshot and the 1fps stream. Android untouched.
2026-06-22 23:22:43 -05:00
ScreenTinker 0ebbd20968 fix(player): composite multi-zone layouts in screenshot/stream capture
captureAndSend() grabbed a single querySelector('video'|'img') and stretched it
across a fixed 960x540 canvas, so multi-zone Now-Playing screenshots and the 1fps
remote stream showed one zone stretched fullscreen instead of the actual layout.

- Multi-zone layouts now composite each zone from its REAL rendered geometry
  (getBoundingClientRect relative to the container, scaled proportionally onto the
  canvas), so positions/sizes stay true to the layout.
- Canvas height derives from the container aspect (not a hardcoded 540); media is
  drawn honouring its object-fit (cover/contain/fill) instead of being stretched.
- Cross-origin / iframe zones (YouTube, widgets) can't be read back without
  CORS-tainting the whole canvas (which makes toDataURL throw and kills the entire
  capture). They now get a deliberate, labelled placeholder ("YouTube"/"Widget"/
  "Video") so the shot still shows the layout structure with that zone marked,
  instead of a transparent hole or a failed capture.
- Split rendering into renderCaptureCanvas() (socket-free, headlessly verifiable)
  and captureAndSend() (encode + emit). One full-quality path serves BOTH the
  on-demand screenshot and the 1fps stream — the composite is only a few drawImage
  calls over already-decoded media, so no separate low-quality stream path.

Web player only; Android (view.draw already composites correctly) untouched.
Verified headlessly on a 3-zone device: red/green image zones render in their
correct positions, the YouTube zone shows a labelled placeholder, and the capture
succeeds with no CORS taint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:22:12 -05:00
ScreenTinker 184f07c272 Merge fix/mute-and-zone-orphan-fallback: per-item mute round-trip + multi-zone orphan-zone fallback
Brings in the mute pair (GET /api/devices/:id muted column + Android YouTube mute),
the zone-orphan fallback (web + Android player recovery, lib/zone-validate single
source, assign-time clearing, dashboard warnings + i18n), and 5 server regression
tests guarding the data contracts. 172/172 pass.
2026-06-22 23:21:35 -05:00
ScreenTinker a36880b147 fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings
Two independent multi-zone bugs, plus operator-facing warnings, i18n, and
regression tests guarding the data contracts.

Bug 1 — per-item mute was a no-op end to end:
- GET /api/devices/:id dropped the `muted` column from its assignments SELECT,
  so the dashboard toggle never reflected state (the muted=false case in
  particular). Column restored to the device payload.
- Android player now honours the per-item mute flag for YouTube (initial state
  + live via the IFrame JS API).

Bug 2 — items whose zone_id belongs to a different layout were silently dropped:
- Player fallback (web + Android): an orphaned zone_id is recovered into the
  largest zone instead of vanishing, with telemetry.
- server/lib/zone-validate.js is the single source of truth for the orphan rule
  (zone not in the device's active layout); used by the device payload
  (per-item `orphan` flag + `active_layout_zones`) and the device list
  (`orphan_count`).
- Assign-time hardening: a stale zone_id (not in the device's active layout) is
  cleared to null on POST/PUT rather than persisted as a new orphan.
- scripts/find-orphan-zone-items.js: read-only sweep for existing orphans.

Dashboard warnings (operator-facing, never on the live player):
- Per-item badge + reassign affordance, device-list glance, preview banner.
- Graceful degradation: the zone selector falls back to /api/layouts/:id so it
  can't vanish on a stale payload.

i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design;
count strings interpolate through tn()).

Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the
data contracts above (muted true/false round-trip, active_layout_zones, orphan
flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:16:29 -05:00
ScreenTinker fa3aed720f chore(release): v1.9.1-beta5
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-06-19 15:33:51 -05:00
screentinker 1f2e923005
fix(#134): quiet false "reconnect" log + report HDMI output and UI render resolution (#136)
Two device-REPORTING fixes from the #134 investigation (the PiP rendering itself
was #135).

1) "Device reconnects every ~45s" was a logging artifact, not instability. The
   player re-emits a full device:register on the SAME socket every ~45-60s
   (requestPlaylistRefresh) to pull a fresh playlist; the server logged
   "Device reconnected" for every register of a known device. The attached 4-day
   log showed 1415 "reconnected" vs 30 real socket connects and 0 heartbeat
   timeouts — the socket never dropped, so #134's "PiP lost between reconnects"
   was a misdiagnosis. Fix: only log a genuine reconnect (new socket); a
   same-socket re-register is a refresh (currentDeviceId === device_id) and stays
   quiet. The playlist still refreshes.

2) Device reported 720p while the monitor showed a 1080 signal. DeviceInfo
   reported getRealMetrics() — the UI RENDER SURFACE — but TV boxes render the UI
   at 720p and upscale to a 1080p HDMI signal. Now report BOTH: screen_width/height
   = the output mode (Display.Mode.physicalWidth/Height), render_width/height =
   the render surface (getRealMetrics). Two new nullable devices columns, stored on
   pairing INSERT + reconnect UPDATE, exposed via the device API, shown on the
   dashboard as "1920x1080 (UI 1280x720)" when they differ.

Backward compatible (required + verified on emulator): a device that omits
render_* — or sends no device_info at all — still registers, with render_* = null,
on both the INSERT and UPDATE paths. New columns nullable; stores use
`?? null` / `|| null`. All 167 server tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:32:11 -05:00
screentinker 7660d7433e
fix(#109): render Android PiP overlay above the YouTube WebView video plane (#135)
* fix(#109): render Android PiP overlay above the YouTube WebView video plane

The PiP overlay (#109) returned sent:1 and showed its title in `uiautomator
dump`, but nothing painted on screen while YouTube was playing. By elimination
(YouTube-specific, landscape so no off-screen transform, real on-screen bounds
in the dump) the cause is surface occlusion: pipLayout sat as the last child of
rootLayout — the SAME compositing band as R.id.youtubeWebView — so the playing
video surface drew over it.

Fix (task option 1a): reparent pipLayout out of rootLayout to the window
content (android.R.id.content) as a top-level sibling drawn after rootLayout, so
it composites above the WebView. MainActivity.mirrorTransformToPip() copies
rootView's orientation/wall transform onto it so corner positions still track
the rotated content (web/Tizen parity). show() also bringToFront()+
requestLayout()+invalidate() on attach (covers the cause-3 measure/visibility
path). Remote-view screenshots now capture the content root so the PiP is still
included.

Instrumentation (Phase 1, default OFF): PipOverlay.pipDebug paints a solid
magenta box + border with media on top (box paints even if media never loads)
and logs box/pipLayout/rootView/youtubeWebView geometry over device:log tag
"pip"; loadImageInto also logs on success. Toggled via device:command
{type:"pip_debug"} (routed through MainActivity.onCommand).

Server: POST /api/pip and the clear handler log one concise [pip] dispatch line
(target + sent/offline) so journalctl shows PiP activity.

Validated end-to-end on an emulator (pixel10/API34) paired to an isolated local
server with YouTube playing: no crash, the PiP box composites above the live
video frame (center + top-right), clear removes it, and the portrait transform
mirror rotates the overlay with the stage (no off-screen). The Fire TV
hardware-overlay punch-through still needs real hardware (emulator composites
video inline); pipDebug + docs/109-android-pip-visibility.md cover that.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(#109): image PiPs never painted — set slot token before decode

Emulator e2e of an image PiP (a QR PNG) found the image area always blank (box
background + title only). Pre-existing defect, also on main, independent of the
occlusion reparent.

Root cause in PipOverlay.show(): teardown() clears `current` to null, then
loadImageInto() captured `token = current` (null) as its drop-if-replaced guard,
but `current` was set to the new pip_id AFTER the media was built. The image
decode finishes on a background thread and posts back after show() returns, so
`token != current` (null != pip_id) was always true and every decoded bitmap was
dropped. Web PiPs and the box/title were unaffected, which masked it.

Fix: set `current = pip_id` before building media so loadImageInto's token
matches. Verified on emulator — a QR image PiP now renders over both a static
image and live YouTube (hardware screencap + the app's software view.draw
capture both show it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#109): record web PiP (HTML+JS) verification on emulator

Web PiP type loads its WebView and executes JS (a page stamping JS OK · <time>
rendered over live YouTube). No code change — web PiPs don't use the image path
that had the token bug. Completes the image/web/box content-type verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(#109): implement PiP close_button on Android (was a documented no-op)

The server forwarded close_button (routes/pip.js) and it's in openapi.yaml, but
no player rendered it — Tizen deferred "close-button focus" as non-MVP, the web
player has none, and Android's PipOverlay never read the flag. So the documented
field did nothing on any device.

Implement it on Android: when close_button:true, a tappable ✕ floats at the box's
top-right in a FrameLayout wrapper that is a SIBLING of the box — so it isn't
clipped by the box outline or dimmed by the overlay opacity. Tapping it clears
THIS overlay (id-matched via the captured token). Only the ✕ is clickable; the
rest of the full-screen pipLayout stays touch-transparent, so taps elsewhere
fall through to the playing content (no input regression).

Verified on the emulator over live YouTube: the ✕ renders at the corner, and
tapping it removes the overlay while the video keeps playing.

Parity note: web/Tizen players still don't implement close_button; D-pad focus
of the ✕ on non-touch TV hardware is intentionally not wired (MVP = touch/pointer,
matching the Tizen focus deferral).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:49:20 -05:00
screentinker 89cbcac2cd
Add PIP-Weather-Radar example (TV-style live radar overlay) (#133)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
* Add PIP-Weather-Radar example (TV-style live radar overlay)

A "cut to radar" PiP recipe: a Leaflet map (vendored locally for the
CSP) with a CARTO dark basemap, an animated RainViewer radar loop, and
live NWS warning polygons drawn and color-coded (tornado/severe-tstorm/
flash-flood/flood) with a pulsing "LIVE RADAR" HUD, count chips, and a
legend. Auto-frames the view to the active warning polygon(s).

Two modes: "always" (radar always up) and "on_warning" (default) which
shows the radar only while a qualifying warning covers the configured
point and clears it when the warnings expire — like a station breaking
in during severe weather.

100% keyless / open data: RainViewer radar, CARTO/OSM basemap, NWS
alerts. Zero Node deps; Leaflet is vendored client-side via
vendor-leaflet.sh (gitignored). Offline test covers the warning gate,
color map, RainViewer tile-URL builder, and overlay-URI round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(radar): note Leaflet is vendored locally, not committed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:05:19 -05:00
screentinker 0b138f10c6
Add PiP overlay example recipes (#132)
Self-contained examples for the PiP overlay API (POST /api/pip), each
with a CSP-safe query-param overlay (external JS), config.example.json,
zero runtime deps, an offline test, and a README:

- PIP-Announce-Broadcast    manual one-shot message to a screen/group
- PIP-Weather-Widget        Open-Meteo current conditions (keyless)
- PIP-Air-Quality           Open-Meteo US AQI widget (keyless)
- PIP-Crypto-Ticker         CoinGecko price strip (keyless)
- PIP-News-Ticker           scrolling RSS/Atom headlines
- PIP-Room-Status-Calendar  ICS-driven Available/Busy room sign
- PIP-Event-Countdown       client-side countdown, auto-clears at zero
- PIP-Welcome-Board         rotating welcome/birthday cards from CSV
- PIP-Fundraiser-Thermometer goal-progress bar from local/URL JSON
- PIP-QR-Rotator            rotating QR codes, encoded client-side
- PIP-Incident-Webhook      event-driven: red on firing, clear on resolved

Also includes the CAP-AU (NSW RFS) and US NWS/NOAA emergency-alert
monitors that push expiry-aware PiP overlays.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:20:37 -05:00
ScreenTinker 5f83fc20d3 docs(api): document /api/pip and the assignments muted field (#109/#129)
The PiP endpoints and the per-item mute field shipped without OpenAPI coverage.

- openapi.yaml: add POST /pip (show), DELETE /pip + POST /pip/clear (clear), all
  x-required-scope: full; add the `muted` boolean to PUT /assignments/{id}; add a `pip` tag.
- openapi-contract.test.js: the scope heuristic only treated `command` paths as full-scope,
  so a full-scope non-command route (/pip) would fail it — extend it to recognize /pip.

Docs-only as far as the running build goes (no route/behavior change). Lands on main; not
in the frozen v1.9.1-beta4 tag — ships in the next tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:12 -05:00
ScreenTinker 71f8948bdb chore(android): bump versionName to 1.9.1-beta4 (versionCode 24)
The Android version fields live separately from the root VERSION / server package,
so the beta4 release commit didn't touch them and the APK reported beta3. Bump them so
the client reports beta4 and OTA sees it as newer. The v1.9.1-beta4 tag is intentionally
NOT moved to this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:28:44 -05:00
ScreenTinker 118367837b chore(release): v1.9.1-beta4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:00:59 -05:00
screentinker 618a7048c6
fix(server): proxy remote YouTube thumbnails + real version in boot banner (#131)
* fix(server): proxy remote YouTube thumbnails instead of ENOENT on a local path

YouTube content stores thumbnail_path as a REMOTE URL
(https://img.youtube.com/vi/<id>/hqdefault.jpg), but the thumbnail-serving route
path.resolve'd it into contentDir -> a local file that never existed -> ENOENT logged
a few times a minute (the tester-log spam). Recreating content didn't help (new rows
store the same remote URL).

- GET /api/content/:id/thumbnail now proxies a remote http(s) thumbnail_path
  server-side (same-origin, so dashboard CSP img-src is unaffected) via a non-throwing
  helper: upstream 404 -> 404, other failure/timeout -> 502, image/* only (modest SSRF
  hardening; the URL is server-set at ingest). Local thumbnails keep the sendFile path;
  the playlist/widget/workspace access gating is unchanged for both branches.
- routes/widgets.js inlineUserContent skips the disk read for a remote thumbnail and
  leaves the /api/content/:id/thumbnail reference in place (the proxy serves it).
- routes/content.js ingest unchanged; a comment notes the future download-at-ingest +
  backfill option for CDN independence.
- New test/thumbnail-proxy.test.js: local sendFile still works; a remote thumbnail is
  proxied (mock upstream, no local read, no ENOENT); upstream 404 -> clean 404. Full
  server suite 164/164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): boot banner shows the real version, not a hardcoded v1.2.0

The startup ASCII banner printed "ScreenTinker Server v1.2.0". Use the already-imported
VERSION (require('./version'), the single source of truth that reads the root VERSION
file) in a fixed-width field (VERSION.padEnd(22).slice(0, 22) — the same padEnd
discipline the port line uses) so the fixed-width box border stays aligned for any
version length. No other behavior changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:00:24 -05:00
screentinker 6f0e4a07f6
Fix per-item mute (#129): persist, ship to device, and toggle in real time (#130)
* fix(server): persist + ship + real-time per-item mute (#129)

The dashboard mute toggle was a no-op end to end. The active model is playlist_items
(the device payload is its published_snapshot); the legacy `assignments` table the bug
report cited is unused for devices. Three breaks:

- PUT /api/assignments/:id silently dropped `muted` (only read sort_order/duration_sec/
  zone_id). It now accepts muted (coerced 0/1) and ITEM_SELECT returns it, so the toggle
  persists and its on/off state sticks.
- playlist_items had no `muted` column — added (schema + idempotent migration).
- buildSnapshotItems didn't select muted, so it never reached the published_snapshot /
  device payload — now included.

Real-time: on a mute change, emit device:mute-changed { content_id, widget_id, muted } to
every device on that playlist so the player toggles the matching item's volume live,
decoupled from publish (the value is also in the next snapshot, so it persists). Adds a
[mute] log line (the report noted zero mute log entries).

Test: test/mute.test.js — PUT persists + returns muted, it reaches the published
snapshot, and a non-mute update doesn't reset it. Server suite 164/164.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(player): apply per-item mute live on Android + web (#129)

Honor the new per-item mute from the server, both in real time and on reload.

Android:
- WebSocketService: onMuteChanged callback + main-thread device:mute-changed handler.
- MediaPlayerManager.setVideoMuted(): flips the live ExoPlayer volume on the current
  video (YouTube autoplays muted; images/widgets are silent).
- MainActivity: on device:mute-changed, apply immediately if the toggled item is the
  one playing now.
- PlaylistController.sig(): include muted so a published mute change re-renders/persists
  instead of being de-duped.

Web player (server/player/index.html):
- device:mute-changed handler toggles the current <video>; the video mount now also
  honors item.muted so a published mute sticks across reloads.

Tizen intentionally not included: its player mutes ALL video for autoplay, so per-item
unmute isn't achievable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:54:23 -05:00
screentinker 965920cd17
PiP overlay MVP: push image/web overlays to a device or group (#109) (#127)
* PiP overlay MVP: push image/web overlays to a device or group (#109)

Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.

Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
  title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.

Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
  per-device — reusing the group command route's room-size online check and
  {device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
  width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
  (#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
  bound to workspace A can't address workspace B (404). Offline devices are reported,
  never queued (PiP is ephemeral).

Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
  positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
  sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
  (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
  the layer. Reports show/clear over device:log (tag 'pip').

Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.

Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
  (wsA token -> wsB device 404), payload validation, device + group targeting, clear;
  plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
  shim; proves the overlay shows, auto-dismisses on the duration timer, and never
  changes the playlist signature / touches #stage; web->iframe, last-show-wins,
  id-aware clear, malformed-payload safety.

Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
  Android layer (an overlay View above the player, orientation-matched to MainActivity's
  rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
  'command' paths as full-scope, so documenting a full-scope non-command route there
  needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
  (x,y)/selector positioning (proposal §6).

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PiP overlay: add Android + web players (#109)

Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show /
device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes —
the route and socket messages are player-agnostic; these are the two missing surfaces.

Web player (server/player/index.html):
- New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist
  render never touches. The same orientation transform is applied to it as to
  #playerContainer (extended to also reset width/height on landscape so a
  portrait->landscape switch realigns), so corner positions track the visible content.
- Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe>
  (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot
  last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown.
- device:pip-show/clear handlers; reports show/clear over device:log (tag "pip").

Android player:
- activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws
  above the content AND inherits rootView's orientation rotation/translation, so corner
  positioning is orientation-matched for free.
- PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded
  off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with
  mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center
  placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional
  title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped
  and also run on activity destroy (WebView cleanup).
- WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main
  thread (they build Views) + a sendLog(tag, level, message) emitter for device:log.
- MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the
  callbacks, tear down on destroy.

Verified: Android assembleDebug builds clean; web player inline JS parses; server suite
still 161/161 (no server changes this commit). Not yet validated on real hardware —
four-orientation corner positioning mirrors the player container/rootView transform but
should be eyeballed on a panel.

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:54:44 -05:00
ScreenTinker e2ff8f47b7 Tizen player: real Samsung B2B fleet control (#125), folding in #126
#123 already shipped a placeholder device:command handler (#121/#122): screen_off
was a black overlay, reboot/shutdown a toast, update a "re-install" toast. This
replaces that with the real control surface from #125, reconciled into the single
handler #123 introduced (rather than landing a second, competing handler).

- NEW tizen/js/device-control.js: window.STDeviceControl = { run, capabilities,
  backend }. Feature-detects webapis.systemcontrol.* (Tizen 6.5/7, sync/throws) then
  b2bapis.b2bcontrol.* (SSSP/Tizen 4, async), normalises both to Promises, re-probes
  each call. run() never rejects; resolves { ok, supported, action, note, reload }.
  Panel power: setPanelMute (mute ON = backlight OFF) -> setDisplayPanel/setPanelStatus
  fallback. reboot -> rebootDevice(); shutdown mutes the panel and notes SSSP has no
  true power-off; update/reload -> reload:true.
- tizen/js/app.js: device:command now calls STDeviceControl.run and reports the
  outcome via reportCmd (device:log tag=command -> dashboard:device-log, plus a
  structured device:command-result), reloading ~1.2s later on result.reload. screen_off
  falls back to the existing black overlay (showScreenOff) when no B2B surface exists;
  screen_on/launch still clear the overlay + keepAwake. Dropped the now-dead
  tryPowerControl. reportCapabilities() runs on device:registered so the dashboard sees
  the backend ("none" on web/URL-Launcher/consumer TV).
- tizen/config.xml: partner-level b2bcontrol + systemcontrol privileges (ignored, not
  fatal, on unsigned/URL-Launcher/web/consumer builds).
- tizen/index.html: load $WEBAPIS/webapis.js + $B2BAPIS/b2bapis.js before the app
  scripts (404 harmlessly off-hardware) and device-control.js before app.js.
- tizen/README.md: rewrote the remote-control table for real B2B control + a
  partner-signing caveat; added device-control.js to the file list.

Supersedes PR #126 (feat/tizen-device-command-125), which targeted main unaware that
this branch already had a device:command handler.

Verified: node --check on both JS files; config.xml well-formed (xmllint). Not yet
validated on a real SSSP panel — the control surface only takes effect on a
partner-signed .wgt (backend reports "none" on the dev/URL-Launcher build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:28:08 -05:00
ScreenTinker 9c4b48800f Tizen player 1.9.1-beta3: bug fixes, multi-zone layouts, video walls
Brings the Tizen TV player to parity with the other players: closes the five
Tizen issues Bold Media Group filed (#118-#122) and adds the two larger renderer
features it was still missing.

Fixes (#118-#122)
- #118 Sticky "Not authenticated" banner. On TV sleep/wake the socket reconnects
  and a heartbeat could land on the fresh, not-yet-registered socket; the server
  rejected it and the old handler painted a permanent banner AND dropped the saved
  credentials, forcing a re-pair. Heartbeats are now gated on a per-connection
  authenticated flag (true only between device:registered and disconnect/auth-error),
  the heartbeat stops on connect/disconnect/auth-error, the banner clears on
  device:registered, and the auth-error toast is non-sticky.
- #119 app_version stuck at 1.0.0. Resolved at runtime from config.xml via the Tizen
  application API, with a fallback constant that build-wgt.sh stamps from config.xml.
- #121 Remote commands. Added a device:command handler (refresh/launch/screen_on/
  screen_off; honest no-op toasts for update/reboot/shutdown, which need B2B/MDM
  privileges a sideloaded app lacks). Removed the dead device:reload listener.
- #120 Dashboard preview. Added device:screenshot-request + remote-start/remote-stop.
  Images capture; video/YouTube fall back to a status card (TV hardware video plane
  and cross-origin iframes can't be read into a canvas).
- #122 Updates/boot. Documented the real paths (re-sideload or URL Launcher/MDM
  refresh; display-level kiosk/boot settings) since a sideloaded .wgt has no in-app
  OTA or config.xml autostart.

Multi-zone layouts (Android parity)
- New ZoneRenderer ports the Android ZoneManager: zones positioned by percent
  geometry with z_index/fit_mode/background, assignments grouped by zone_id
  (unassigned content goes to the first zone), each zone rotating independently with
  the same per-item schedule gating (#74/#75). app.js selects the renderer from
  payload.layout; single-zone playback is unchanged.

Video walls (web-player parity; Android has none)
- New WallController mirrors the web player: when payload.wall_config is present the
  stage is positioned (vw/vh) as this screen's slice of the wall. The leader plays
  normally and broadcasts wall:sync at 4Hz; followers hold the leader's item, align
  index, and lock their video to the leader's clock with a latency-compensated drift
  controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s), and
  request an immediate position on (re)connect via wall:sync-request. Per-tile
  rotation is not applied yet (matches the web player). Wall emits are gated on
  auth + connection so a pre-register tick can't trip device:auth-error.

Not ported: video-wall per-tile rotation, plus the minor Android-only reporting
events (device:playback-state, device:log) and the N/A offline-cache events
(device:content-ack/content-delete). None affect on-screen playback.

Verified: JS syntax + headless unit tests of zone grouping/geometry and wall
leader/follower + drift logic. NOT yet validated on Tizen hardware - multi-screen
video sync in particular needs a real wall to tune.
2026-06-18 13:28:08 -05:00
ScreenTinker 0cd2a904e5 Android player: video-wall (wall:sync) support
Ports the wall:sync protocol the web and Tizen players already ship to native
Kotlin/ExoPlayer, so the Android player can join a video wall.

- WallController (new): 4Hz leader broadcast; follower latency-compensated drift
  controller (hard-seek past 0.3s, gentle +/-3% playbackRate nudge past 0.05s);
  role handling with immediate align on entry and on wall:sync-request. Per-tile
  rotation intentionally not applied (web/Tizen parity; left as a TODO).
- MediaPlayerManager: expose position/duration/seekExact/setSpeed for the drift
  controller; RESIZE_MODE_FILL / ImageView FIT_XY in wall mode (object-fit:fill
  parity), restored to fit/fitCenter on exit. Follower mute (setWallMute) persists
  across leader-driven item switches, and followers loop (REPEAT_MODE_ONE) so they
  never freeze on the last frame if the leader's next index is late.
- PlaylistController: wallFollower flag suppresses auto-advance (leader drives the
  index); getIndex/gotoIndex for follower tracking; itemStartedAtMs for non-video
  sync position.
- WebSocketService: onWallSync/onWallSyncRequest handlers (posted to the main
  thread since they drive ExoPlayer) + emitWallSync/emitWallSyncRequest senders
  guarded on socket.connected() like sendPlaybackState.
- MainActivity: parse wall_config in onPlaylistUpdate and branch before the
  orientation + multi-zone paths; size/translate rootView to this screen's slice;
  exit() restores full screen.

Compiles clean (./gradlew :app:assembleDebug). NOT yet validated on a device or a
real wall — the ExoPlayer seek/speed sync and the slice transform need on-device
tuning before this is trusted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:16:29 -05:00
ScreenTinker 10726fde42 Merge #117: HIDE_BILLING flag to hide the Subscription/billing UI (#116)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
2026-06-16 09:57:17 -05:00
ScreenTinker 674a34ba45 feat(config): HIDE_BILLING flag to hide the Subscription/billing UI (#116)
Opt-in, default-off UI gate (per strobe's spec; verified his file refs first).
When set, hides the Subscription sidebar item + billing view and bounces
#/billing to the dashboard. Billing shown by default -> existing deployments
unchanged. UI-only: /api/subscription/* untouched (internal usage reads stay).

- config.js: config.hideBilling from HIDE_BILLING (mirrors selfHosted).
- auth.js: surface hide_billing on GET /api/auth/me (client already fetches it
  at boot, stored on the user object).
- index.html: id="billingNavItem" on the Subscription <li> (mirrors adminNavItem).
- app.js: toggle billingNavItem in updateSidebarUser (next to the admin toggle);
  guard #/billing -> history.replaceState('#/') + render dashboard (replaceState
  so the back button doesn't loop into the guard).
- .env.example + README documented.

Spec assumptions verified against code: adminNavItem toggle pattern exists;
/me is fetched at boot and updateSidebarUser runs both at boot (cached user)
and post-/me, so no-flash holds on warm loads (one-time flash possible on the
first load after the flag flips — same as the admin nav, minor); route dispatch
is an if/else chain. Nav label is static (no data-i18n) so no i18n change.

Validated (headless Chrome, both states):
- flag unset -> Subscription tab present, #/billing renders (backward-compat).
- HIDE_BILLING=true -> tab hidden, #/billing redirects to #/.
- config maps HIDE_BILLING both ways; live /me default hide_billing=false.
- 149 server tests green. Default-off = zero change for existing deployments.

Known cosmetic (harmless): after the redirect the billing nav LINK keeps its
'active' class, but the nav item is display:none so it's never visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:19:24 -05:00
ScreenTinker 5b13254de3 chore(release): v1.9.1-beta3
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-15 16:07:04 -05:00
ScreenTinker c5e8067b35 Merge #115: last-resort uncaughtException safety net (#114) 2026-06-15 16:02:18 -05:00
ScreenTinker 78a4ee4d37 fix(server): last-resort uncaughtException/unhandledRejection safety net (#114)
A FK constraint violation crashed the whole process on 1.9.1-beta2 with a
bare "FOREIGN KEY constraint failed" and NO stack — so it couldn't be root-
caused. better-sqlite3 is synchronous, so such a throw inside a socket.io
handler (no local try/catch) propagates to uncaughtException, and with no
handler Node exits traceless.

Add a small top-of-server.js net that logs the FULL err.stack (file:line of
the offending write) + timestamp, best-effort closes the DB (WAL flush), then
exits(1) so systemd restarts fresh. NOT catch-and-continue — after an uncaught
throw the process state is undefined, so we never keep serving. This is the
investigation tool the root-cause fix is blocked on, plus the fleet-wide-crash
net #114 asked for.

Verified (not assumed):
- A synthetic synchronous FK throw inside a real socket.io handler IS caught by
  uncaughtException, logs the full stack incl. the throwing file:line, exits 1.
- Non-over-reach: a FK throw in an Express route -> Express handles it (500), a
  throw in a local try/catch -> caught (200); the global net does NOT fire and
  the process stays alive. Last resort, not a catch-all.
- 149 server tests green; server boots clean (net doesn't trip on startup).

The root-cause FK fix is SEPARATE and waits on the stack trace this produces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:57:17 -05:00
ScreenTinker 7539603b17 Merge #111: device-free preview, playlist + device surfaces (#104) 2026-06-15 15:20:57 -05:00
ScreenTinker 647a7de1e6 Merge #112: duplicate + replace playlist items (#105) 2026-06-15 15:20:51 -05:00
ScreenTinker d2feb2a3c5 Merge #113: drag-to-reorder display tiles (#106) 2026-06-15 15:20:17 -05:00
ScreenTinker 5d24c30ea1 feat(displays): drag-to-reorder display tiles within a section (#106)
Option A: tile-on-tile (same section) reorders; tile-on-section / cross-
section stays group-assign (existing behavior untouched). Ordering is
cosmetic (dashboard only — nothing the device/player reads).

Backend:
- Migration: devices.sort_order column (idempotent ALTER; default 0).
- GET /api/devices ordering: sort_order ASC, created_at ASC (was created_at).
- POST /api/devices/reorder — ordered id array -> transactional
  UPDATE sort_order=index, scoped WHERE workspace_id = caller's workspace
  (forged cross-workspace ids are no-ops). Write-gated (viewer read-only).
  Mirrors the playlist items reorder.

Frontend (the collision):
- Card-level dragover/drop: reorder ONLY when target is another card in the
  SAME section; otherwise no-op so the event bubbles to the section's
  group-assign handler. stopPropagation on the same-section drop prevents
  the section handler also firing. Drop indicator (inset box-shadow).
  Native HTML5 DnD; no library.

Validated (headless Chrome, synthetic DnD + a section-level drop spy):
- SAME-section reorder: section drop suppressed (sectionDrops=0), POST
  /devices/reorder fires, NO group call, sort_order persists in DB.
- CROSS-section: section drop fires (sectionDrops=1), POST /groups/:id/
  devices fires and membership actually changes — group-assign unbroken.
- The 0-vs-1 contrast proves stopPropagation disambiguates the shared gesture.
- 149 server tests green; migration applies clean on the prod-copy DB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 15:15:21 -05:00
ScreenTinker cbabbeb78c feat(preview): device-manager preview — second surface for #104 (combined)
Completes #104's two surfaces by reusing the now-generalized player preview
for devices, seam-safe (device-bound layout, NOT playlist-derived).

Server:
- GET /api/devices/:id/preview-payload returns buildPlaylistPayload(deviceId)
  — the device's OWN layout/orientation (device row) + its published items —
  with wall_config forced null (v1: wall members preview full-frame; a
  socket-free follower would otherwise freeze waiting for leader wall:sync).
  Device-READ gate (mirrors GET /:id, viewers allowed); NOT requirePlaylistRead.

Player (generalized, shared seam):
- Boot dispatch now accepts ?preview=1 with EITHER playlist=ID OR device=ID.
- bootPreview(qs) builds the right URL; shared body factored into
  renderPreviewFromUrl(url) used by both. Renderer still UNTOUCHED.
- derivePreviewLayout stays PLAYLIST-only; never touches the device path.

Dashboard:
- Device manager gets a Preview button -> /player?preview=1&device=ID
  (modal iframe, aspect from device orientation). Playlist-view button as-is.
- i18n x6 (device.preview_btn).

Validated (not just tests): 149 server tests green (generalization didn't
break the playlist path); device preview renders socket-free in headless
Chrome; layout proven device-bound on real data (device playlist has 0 zoned
items -> playlist-derivation would give NULL, but payload returns the device
row's "Vertical Full HD"); wall-member device previews full-frame (inWallMode
false) without freezing; auth gate outsider->403, no-token->401; playlist
path still renders the webpage note post-refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:57:19 -05:00
ScreenTinker e6ebf2a380 feat(playlists): duplicate + replace playlist items in place (#105)
Duplicate and Replace per-item actions, both leaning on the normalized
playlist_items schema (only content_id/widget_id/zone_id/sort_order/
duration_sec; type-specific fields are JOINed at snapshot time).

- Replace: extend PUT /:id/items/:itemId to accept a content_id/widget_id
  swap. Clean FK swap across ANY content type (image<->video<->youtube<->
  widget) — sets one, nulls the other, preserving zone_id/duration/
  sort_order/schedule rows. Only acts when content_id|widget_id is present,
  so partial PUTs are unaffected. Workspace-validated; markDraft.
- Duplicate: new POST /:id/items/:itemId/duplicate — copies the row +
  its schedule blocks (new ids) in one transaction, appended (sort_order
  MAX+1). markDraft.
- Frontend: Replace + Duplicate icon buttons per item; Replace reuses the
  add-item picker in a replaceItemId mode (PUT instead of POST). i18n x6.

Validated end-to-end against the live API: duplicate (incl. schedule copy
with distinct ids), replace same-type and cross-type both directions,
preservation of duration/schedule/zone, and validation (both->400,
missing->404). 149 server tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:36:19 -05:00
ScreenTinker 1c748b8d3b feat(preview): draft-aware device-free playlist preview via player reuse (#104)
Replaces the broken/fragmented preview with a single surface that renders a
DRAFT playlist exactly as a device does, by reusing the player's renderer in a
same-origin iframe. Fixes "not all items load" (one renderer, full type union)
and inherits the player's YouTube correctness (YT.Player handshake).

Server:
- deviceSocket: extract assemblePayload() (zone-reset + canonical shape) from
  buildPlaylistPayload so the device path and preview can't drift. Pure refactor
  (all 149 tests green).
- playlists: GET /:id/preview-payload (requirePlaylistRead, workspace-scoped).
  Draft-aware via buildSnapshotItems (live items, not published_snapshot);
  derivePreviewLayout() resolves layout from the playlist's own zone-bound items
  (0 zoned -> fullscreen; 1 -> use it; >1 -> dominant + ambiguous flag, never
  crashes). orientation validated/passthrough; wall_config/timezone null.

Player (renderer UNTOUCHED):
- ?preview=1&playlist=ID boot branch: fetch preview-payload (same-origin Bearer
  token) and call handlePlaylistUpdate(). Gated before the pairing/socket path
  so the unpaired auto-connect never fires. All socket emits already guarded.
- Webpage widgets: always-visible honest note (no auto-detection — an XFO
  refusal is provably indistinguishable client-side from a working embed).

Dashboard:
- playlists: Preview button + player-iframe modal with landscape/portrait toggle.
- widgets: same honest note on the existing widget preview modal (the surface the
  bug was reported on).
- i18n x6 (en/es/fr/de/it/pt) + player i18n x5.

Validated end-to-end (headless Chrome + CDP): preview boots, webpage note
renders, 3-zone layout derives+renders, shape parity with device snapshot proven
on real data, auth gate returns 401. The world-readable /uploads finding is
tracked separately as #107 (not a #104 concern — same path the device uses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:11:05 -05:00
ScreenTinker d64244b5ac chore(release): v1.9.1-beta2
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-14 20:34:21 -05:00
ScreenTinker a21843818c fix(release): bump-version handles a pre-release CURRENT version
The package.json + android versionName seds matched only a clean X.Y.Z, so bumping FROM a
pre-release (1.9.1-beta1 -> 1.9.1-beta2) left those two files stale while VERSION advanced -
an inconsistent release. Allow a [^"]* suffix so the current value's -beta1 is matched and
replaced. Still matches clean versions (regression-checked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:33:37 -05:00
ScreenTinker fbd466b7f2 fix(docker): copy docs/openapi.yaml into the image so /docs's spec resolves
The /openapi.yaml route does res.sendFile(../docs/openapi.yaml) -> /app/docs/openapi.yaml in
the container, but the Dockerfile copied server/, frontend/, VERSION, scripts/ and never docs/,
so the spec 404'd in every deployed build (Redoc's /docs page loaded but couldn't fetch the
spec). Served fine from a dev checkout only because the repo has docs/ on disk. Verified in a
built image: /app/docs/openapi.yaml present, GET /openapi.yaml -> 200 text/yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:32:30 -05:00
ScreenTinker 31be2ffe8c Merge feat/agency-tokens: agency upload portal (#73), full-screen guardrail, YouTube preview referrer fix
20 commits: agency-token security primitive + portal + designate UI + auto-publish + email
digest + size-guidance card + edit-designations + full-screen-only guardrail; zone-binding
reverted; API docs link; portal handoff at creation; YouTube content-preview 153 fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:27:10 -05:00
ScreenTinker 46e4bc8579 fix(content): YouTube preview 153 — give the iframe a referrer (page is no-referrer)
ROOT CAUSE (hard evidence this time, from the response headers): the app sends
Referrer-Policy: no-referrer globally (helmet default). A raw YouTube iframe then reaches
youtube.com with NO Referer, so YouTube can't identify the embedding site and shows "Video
player configuration error" (153). Confirmed by the three facts: the same /embed URL plays in
a top-level tab (no embed check), plays in the device player (YT.Player loads iframe_api and
validates via an ORIGIN postMessage handshake, which doesn't need Referer), and fails only as
a raw iframe on a no-referrer page. The player's page is ALSO no-referrer, proving it's the
embed method that saves it, not the headers.

Fix: add referrerpolicy="strict-origin-when-cross-origin" to the preview iframe — overrides
the page's no-referrer for just this element so YouTube receives our origin and validates the
embed. Scoped (only the YouTube embed sends a referrer; only the origin, not the path), no JS
API machinery needed for a passive preview, page-level no-referrer untouched.

Supersedes the earlier enablejsapi/origin strip, which was inert (those params do nothing in
a raw iframe with no IFrame API). Frontend-only; suite 149 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:12:57 -05:00
ScreenTinker 7f7dc80a8c fix(content): YouTube preview 153 — drop enablejsapi/origin from the passive embed
The content-tab preview embedded a RAW iframe with enablejsapi=1 (baked into the stored
/embed URL by /youtube) plus origin=window.location.origin — but the content tab loads the
YouTube IFrame API zero times. enablejsapi=1 + origin tells YouTube's player to expect a
postMessage handshake from a parent JS API that never exists here, which surfaces as "Video
player configuration error" (153). Same-video proof: it plays on the device player (which
loads iframe_api + uses YT.Player, so the handshake completes) and failed only on the content
tab — so it was never a video/embeddability problem, purely the embed construction.

Fix: the preview is passive (never drives playback via JS), so it must not declare the JS API
— strip enablejsapi + origin, leaving a plain /embed/ID (the form that plays in a bare tab).
Did NOT touch /youtube storage (the player extracts the videoId and ignores stored params, so
the baked-in enablejsapi is harmless there). Retracts the earlier wrong "validate
embeddability at add-time" diagnosis (never built — it would have rejected this embeddable
video). Frontend-only; suite 149 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:18:58 -05:00
ScreenTinker ed45a9a23d feat(ui): surface the agency portal handoff at token creation (#73)
When an agency token is created, the once-shown secret box now also shows the Portal URL
(window.location.origin + '/agency' — the real public host the admin is on, correct behind
Cloudflare, config-free) and a COPYABLE INVITE: "Go to <url> and paste this access key:
<key>". The key lives in the invite TEXT, never in a URL — no magic link, because Cloudflare
logs query strings and chat apps unfurl links (the key would leak on paste). Same exposure as
the key field itself, just with the destination surfaced. The existing "won't see it again"
warning now covers the invite too (it contains the key). i18n x5 (parity test).

Skipped the optional per-row portal URL in the token list: it's the same /agency for every
agency token, so per-row it's noise; the creation invite + the /docs link cover discovery.

Confirmed: invite copy button copies the full "go here + paste key" text; /agency resolves
(200); i18n parity + full suite green (149).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:54:23 -05:00
ScreenTinker 02859eb1aa feat(ui): surface the API docs link in Settings -> API Tokens (#73)
A meaningful link to /docs right under the section header (where someone's creating a token),
opening in a new tab (target=_blank rel=noopener) so it doesn't navigate them away from the
token they're mid-creating. "New to the API? See the full documentation ->" across all 5
locales. /docs (Redoc) already existed; this just makes it discoverable. Confirmed /docs ->
200 Redoc and /openapi.yaml -> 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:44:42 -05:00
ScreenTinker 57d78dd1fa feat: full-screen-only guardrail for agency designations (#73)
Agencies can only be designated FULL-SCREEN playlists (no item with zone_id) - a full-screen
agency upload can't safely target a zone, so the ambiguous case is excluded rather than
solved. Checked at THREE points:
- Designation (tokens.js create + PUT /:id/targets) -> 400: reject a zoned target.
- Upload (agency.js item-add) -> 409: block if the playlist BECAME zoned after designation.
  MANDATORY because auto-publish has no draft net - a full-screen playlist designated to an
  auto-publish token, then zone-assigned, would otherwise auto-publish a full-screen upload
  into a zoned playlist. The upload check is the only thing that catches it.
- Picker (settings.js): zoned playlists greyed/disabled with the reason (GET /playlists now
  returns a zoned flag); backend reject is the guard if the UI is bypassed. i18n x5.

isZonedPlaylist = EXISTS(playlist_items WHERE zone_id IS NOT NULL). Pure restriction - no
zone structure, no api_token_target_zones.

Bite-test (the exact sequence) GREEN and re-proven to bite: full-screen -> designate to an
auto-publish token -> zone-assign the playlist -> agency upload is BLOCKED (409), not
auto-published; neutralizing the upload check makes it go red. 149 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:36:30 -05:00
ScreenTinker 4c38536cc6 feat(ui): edit-designations for agency tokens (#73)
Settings → API Tokens: each agency token gets an "Edit playlists" control that opens the
playlist picker pre-checked with the token's CURRENT designations (from the list GET's
tok.targets), lets the admin add/remove, and calls the existing PUT /:id/targets to
atomically re-designate. Reuses the creation picker pattern; common.save/cancel reused;
edit_targets + targets_updated i18n across all 5 locales. No security-model change - the
endpoint was already proven.

Test (integration): PUT /:id/targets re-designates (add + remove) and the confinement
follows the NEW set - a re-designated token reaches only its new playlists (router.param
403s the removed one). 148 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:04:07 -05:00
ScreenTinker 6ea8100aeb feat(ui): the size-guidance card container in the agency portal (#73)
The #placementCard / #layoutView elements that agency-portal.js's reactive
loadLayoutForPlaylist() renders into. Was built with the card logic but never folded into a
commit; without it the size-guidance card has nowhere to render. Pure markup, no behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:10:25 -05:00
ScreenTinker 400a438fff revert: drop zone-binding, keep whole-playlist grants + size-guidance card (#73)
Investigation found zone placement is a DEVICE property (device.layout_id), not a playlist
property: a normal playlist has no derivable layout (zone_id is NULL unless set in the
device-assignment flow), so a playlist-scoped zone grant can't reach the normal flow. The
right model: placement belongs to the device (same playlist can be full-screen on one screen,
a zone on another); the agency just gets whole-playlist grants + size-guidance.

Removed the zone-grant machinery (security-adjacent dead surface is a liability, not dormant
convenience): api_token_target_zones (schema + a DROP migration for the dev DB where the
short-lived CREATE ran), resolveGrantedZone, grantableZoneIds, buildZoneGrantRows, the
create/PUT zone validation, GET /api/playlists/:id/zones, getPlaylistZones, the settings
zone-picker + its i18n, and the zone-grant bite-test.

KEPT (model-agnostic, good): the reactive per-playlist size-guidance card - GET
/api/agency/playlists/:playlistId/layout (router.param-confined) now reports the zones the
playlist actually feeds (where/what-size content lands), or full-screen when it has no layout.
Whole-playlist grants = today's working model. 147 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:52:11 -05:00
ScreenTinker c5550f5bc9 feat: agency zone-grant issuance UI + reactive placement card (#73)
Issuance (on the proven seam):
- tokens.js create + PUT /:id/targets accept per-playlist zone grants (target_zones), inserted
  into api_token_target_zones inside the same transaction as the playlist grants (FK requires
  the parent, so order matters and is correct).
- Issuance validation (the mirror of runtime confinement): grantableZoneIds() - can grant ONLY
  a zone the playlist's layout actually feeds; can't grant one it doesn't have or one from
  another playlist's layout. Bite-tested. PUT re-designate stays atomic: delete parent rows ->
  zone grants cascade out (no manual child delete).
- settings.js: checking a designated playlist reveals its grantable zones (GET
  /api/playlists/:id/zones, JWT); leave unchecked = whole-playlist. i18n across all 5 locales.

Card:
- GET /api/agency/playlists/:playlistId/layout (rides router.param - confined; a non-
  designated playlist -> 403, asserted). "Your zone" = the GRANTED zones. Retired the
  token-wide /layouts (the per-playlist card replaces the disconnected lump).
- Portal card reacts to the playlist selector: pick a playlist -> its layout renders, the
  granted zone highlighted with px size, siblings as context.

Full suite + agency bite-suite green (154).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:12:55 -05:00
ScreenTinker 289d54f4fa feat(api): zone-grant confinement for agency tokens - FK-anchored (#73)
Placement-as-grant, replacing the inferred auto-place idea. api_token_target_zones is an
ADDITIVE second table (does NOT touch the proven api_token_targets), structurally anchored:
a composite FK to api_token_targets(token_id, playlist_id) makes a zone grant orphan-
impossible and cascade away when the playlist grant is revoked - "narrow" is structural, not
conventional. zone_id FK -> layout_zones cascades on zone/layout delete.

Confinement (lib/agency-targets.resolveGrantedZone, called in the item-add): grants exist ->
the item MUST land in a granted zone (a body zone_id picks among grants, never escapes them);
none -> whole-playlist/full-screen as before. The item-add stamps the granted zone_id.

Bite-tested (6, all proven incl. neutralize->red on the confinement): granted YES; non-
granted/cross-playlist/ambiguous blocked; orphan-grant rejected by the FK; cascade on
playlist-grant revoke, on playlist delete, on zone/layout delete; and foreign_keys=ON
asserted (a cascade that no-ops because FKs are off is the trap). 153 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:57:27 -05:00
ScreenTinker c55ca60b56 feat(api): batched email digest for agency uploads (#73)
Reuses the existing scheduler + sendEmail infra (no new scheduler). The agency endpoint
enqueues one agency_notifications row per item added; a 15-min flush groups unsent rows per
token+playlist+action and sends ONE digest per group to the workspace owner/admins + the
playlist owner (deduped via UNION). Draft -> "added N items, awaiting approval"; published ->
"updated <playlist>".

Two robustness rules, both tested:
- Queue never balloons when SMTP is off: the endpoint skips enqueue when !isConfigured(),
  and the flush drains-and-discards unsent rows as a backstop.
- sent_at is stamped ONLY after a successful send, so a failed send retries next cycle
  instead of silently dropping.

Wired into boot via startAgencyDigest(). 147 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:59:37 -05:00
ScreenTinker 986d94a778 feat(api): GET /api/agency/layouts - device-free layout geometry (#73)
So the agency can size/place content: returns the canvas size + zone positions/sizes for the
layouts its designated playlists feed, marking which zones are theirs. DEVICE-FREE BY
CONSTRUCTION - the query path is playlist_items.zone_id -> layout_zones -> layouts and never
touches devices/groups/schedules, so device names/locations/IPs/topology are structurally
absent, not filtered. Geometry only - no sibling-zone content. layout.name included (admin's
canvas name); thumbnail_data omitted (could render other zones' content).

Confinement query in lib/agency-layouts.js, bite-tested: own layout YES, a non-designated
playlist's layout NO, response has NO device fields (asserted on a db where a location-named
device exists), and neutralizing the t.token_id filter goes red. 142 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:53:30 -05:00
ScreenTinker 1f207c4278 feat(api): per-agency-token auto-publish (#73)
api_tokens.auto_publish (DEFAULT 0 = draft, the fail-safe). Admin sets it at token creation
in the designate UI (checkbox, agency scope only). The agency endpoint reads it from the
TOKEN ROW via req.apiToken (apiTokenAuth attaches it) - NEVER from req.body, so an agency
can't opt itself out of approval. 0 -> markDraft; 1 -> the shared publishPlaylist path.

Tests (integration): draft is the default; a draft token with auto_publish:true IN THE BODY
still lands draft (body ignored); an auto-publish token goes live; manual publish still works
(extraction regression). i18n across all 5 locales. 141 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:17 -05:00
ScreenTinker 79c453cd43 refactor(playlists): extract publishPlaylist() shared fn (#73)
POST /:id/publish snapshots items into published_snapshot (what devices consume) + pushes
to devices. Extracted that into publishPlaylist(id, req) so the agency auto-publish path can
call the IDENTICAL logic - a "published" playlist that wasn't snapshotted would be live on no
screen. The manual endpoint now calls it; behavior-preserving (suite green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:48:17 -05:00
ScreenTinker efd4d7826c feat(ui): standalone agency upload portal (#73)
Agency-facing. A self-contained page at /agency (NOT the dashboard SPA - the agency has no
JWT, only the token). Entry: paste access key -> sessionStorage (cleared on tab close, not
localStorage) -> sent as Bearer. Flow: list designated playlists -> upload (shared ingest =
first-class content) -> date-bounded item on a chosen playlist (lands as draft for admin
re-publish). Graceful failure: any 401/403 resets to the entry screen with "key invalid,
paste it again" - never a wall of 403s. Blast radius of a leaked key stays bounded by the
narrow scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker d59adfd10c feat(ui): agency token designation in Settings (#73)
Admin-facing. Extends the existing API-token UI: an 'agency' scope option reveals a
playlist picker (the workspace's playlists); creating the token binds the checked ones as
its allowlist (target_playlist_ids). The token list shows each agency token's designated
playlists (tokens GET now returns targets for agency-scoped tokens). i18n keys added across
all five locales (parity test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker 6d152a5ccf feat(api): GET /api/agency/playlists - a token's designated targets (#73)
The portal needs to show an agency which playlists it may post to. New read surface on the
security primitive, built with write-path rigor: the confinement query lives in
lib/agency-targets.js (own token + bound workspace only) and is bite-tested four ways -
own targets yes; another token's, outside the allowlist, and cross-workspace all NO;
neutralizing the t.token_id filter makes it go red. Real-path wiring + the portal's
graceful 401 trigger asserted in the integration suite. No :playlistId, so router.param
doesn't apply - the query is the seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:08:07 -05:00
ScreenTinker 40102b2b41 feat(api): agency portal endpoints + router.param target seam (#73)
The agency capability behind the proven off-ladder/agencyGate primitive:
- agencyGate is now SCOPE-only at the mount; the per-target check is router.param
  ('playlistId') in routes/agency.js - it fires WITH the param before the handler, so no
  :playlistId route can skip it (drift-proof). A mount-level target check was silently
  bypassed (Express populates req.params only at route match); the integration bite-suite
  caught it - this is the fix.
- routes/agency.js: POST /content (shared ingest) + POST /playlists/:id/items (date-bounded
  #74/#75 item; lands as draft so the admin's re-publish is the approval gate).
- tokens.js: issue scope='agency' tokens bound to a non-empty in-workspace playlist
  allowlist (atomic); PUT /:id/targets re-designates (JWT-only -> can't self-widen).
- server.js: AGENCY_ROUTERS mounted bearerAuth + resolveTenancy + agencyGate.

Full bite-suite (test/agency.test.js) GREEN and re-proven to bite on the SHIPPING path:
neutralizing the router.param check makes non-designated->403 go red. Four assertions at
three seams: target (router.param), off-ladder (tokenScopeGate), can't-widen (tokens
JWT-only), issuance cross-workspace (create validation). 139 suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00
ScreenTinker a59b53cc25 refactor(content): extract the upload ingest into a shared lib (#73)
routes/content.js POST / processing (thumbnail/dimensions/duration) + insert moved to
lib/content-ingest.js so the agency router produces byte-identical first-class content.
content.js POST / is now a thin caller; behavior-preserving - the 52 content regression
tests (api/operator-permissions/config-paths) pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:48:42 -05:00
ScreenTinker c8a24d2243 feat(api): agency-token security primitive - off-ladder scope + agencyGate (#73)
The capability/target-restricted token model for the agency portal (#73 option B),
proven before any endpoint sits on it:
- 'agency' scope value is OFF the read/write/full ladder, so the existing tokenScopeGate
  rejects it on every public router by construction (auto-confinement, no new code).
- api_token_targets join table: which playlists an agency token may act on.
- agencyGate: THE single seam - agency-scope-only + (playlist in this token's allowlist
  AND in the bound workspace), one query enforcing target + cross-workspace isolation.
- AGENCY_ROUTERS category in config/api-surface.js (mounted with agencyGate, not
  tokenScopeGate) - declared; router/mount land with the endpoints.

Both bite-tested: spine (agency 403s on tokenScopeGate; read/write still pass) and the
gate (non-designated/cross-workspace/non-agency/JWT -> 403; neutralizing the target check
goes red). NARROW - not the general capability-scope system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:30:38 -05:00
ScreenTinker f4c5865013 fix(server): strip totp_secret_enc/totp_last_step from login responses (#100)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Review caught the encrypted TOTP secret riding in the login/verify response body:
issueSession receives a SELECT * user row and only destructured out password_hash, so
totp_secret_enc (and the internal replay counter totp_last_step) leaked. Encrypted, so
not catastrophic, but it regresses the API work's "secrets never in responses" rule.
Strip both in issueSession (covers /login and /totp/verify); add an assertion that a
verify response carries no totp_secret_enc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 728f03beba test(server): TOTP - bite, lockout, replay, recovery, st_ bypass, key-rotation (#100)
Unit: the mfa_pending BITE (db-injected so removing the rejection goes red), lockout, replay,
recovery-hash, decrypt-null graceful. Integration: enrollment, login->mfa_required, route-level
bite, recovery single-use, API-token bypass, verify lockout. Key-rotation: enroll under key A,
reboot under key B -> recovery still works, TOTP fails cleanly (no 500).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 1d3e9acea4 feat(server): TOTP MFA login flow + enrollment/verify endpoints (#100)
Two-token login: /login returns an mfa_pending token when TOTP is on; requireAuth/optionalAuth
REJECT mfa_pending (tightening #1 - else password-alone is a session). /totp/verify exchanges
it + a TOTP or recovery code for a full session (per-user lockout; recovery checked
independently of the decryptable secret). Enrollment: setup -> enable (confirm-then-enable) ->
recovery codes shown once; disable/regenerate require re-auth; regenerate replaces atomically;
status surfaces codes-remaining (tightening #3). API tokens + SSO bypass TOTP by construction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker c38d8dc0e6 fix(server): rate-limit per endpoint, not the stripped req.path (#100)
app.use('/api/auth/login', rateLimit(...)) etc. keyed on req.path, which Express strips to
'/' for mounted middleware - so /login, /register, /totp/verify shared ONE per-IP counter
(coupled limits; the new /totp/verify brute-force limit was not actually independent). Key
on originalUrl instead. Also adds the /api/auth/totp/verify 10/min limit (tightening #2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker c02086e305 feat(server): TOTP primitives - encrypted secret, hashed recovery codes, verify lockout (#100)
lib/totp.js: otplib wrapper; secret stored via secretbox (must be reversible to recompute
codes); recovery codes SHA-256-hashed (api_tokens discipline); verifyCode returns the
matched step and blocks intra-window replay via totp_last_step; decrypt failures return
null (no throw). lib/totp-lockout.js: per-user lockout for /totp/verify (#87 model).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker e1cd8591bb chore(server): TOTP schema + otplib dep (#100)
users.totp_secret_enc (secretbox-encrypted, reversible) + totp_enabled + totp_last_step
(replay guard), and the totp_recovery_codes table (SHA-256 hashed, single-use). Migrations
default everything off so existing accounts are untouched. otplib pinned ^12 (v13 is a
breaking plugin-rewrite with no authenticator/checkDelta).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00
ScreenTinker 1f794ff7b4 chore(release): v1.9.1-beta1
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-12 22:37:50 -05:00
ScreenTinker 6add29bf6a fix(player): auto-relaunch after OTA self-update (#96)
After the OTA installs, PACKAGE_REPLACED kills the old process and nothing brought
MainActivity back, so updating screens dropped to the launcher (the 1.9.0 fleet bug). Add a
MY_PACKAGE_REPLACED receiver that relaunches via a shared Relauncher cascade (extracted from
BootReceiver so boot + post-update share one path):
  1. overlay-direct startActivity (SYSTEM_ALERT_WINDOW) - legal on all versions when granted
  2. full-screen-intent notification - auto-launches <14; on 14+ (USE_FULL_SCREEN_INTENT
     revoked) degrades to a VISIBLE, tappable "tap to resume" prompt - fail loud, never a
     silent dark screen

Emulator-proven on Android 16: MY_PACKAGE_REPLACED -> Relauncher[update] -> overlay-direct
(BAL_ALLOW_SAW_PERMISSION) -> MainActivity on the new version. Accessibility re-binds across
the package-replace (Service connected fires post-relaunch), so sequential OTAs keep their
auto-confirm.

Unattended OTA requires accessibility (auto-confirm the install) + overlay (relaunch); the
setup wizard grants both. A device where they're skipped degrades to the visible prompt.

Closes #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 5bcaca7c51 fix(player): OTA install silently fails on Android 14+ (explicit PendingIntent)
UpdateChecker.tryPackageInstaller built the INSTALL_COMPLETE status PendingIntent with
FLAG_MUTABLE and an implicit intent. On Android 14+ (target SDK 34) that combination is
disallowed - getBroadcast() throws, the inner catch swallows it, and the PackageInstaller
session is never committed. Result: every OTA silently fails to install on a 14+ device
(download succeeds, version never changes). Make the intent explicit via setPackage(),
keeping FLAG_MUTABLE so PackageInstaller can still write EXTRA_STATUS back.

Emulator-proven on Android 16 (API 36): "Package installer session committed" and the
update applies. Distinct from the relaunch bug - this is install-on-14+.

Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:32 -05:00
ScreenTinker 8d03741713 feat(server): make OTA observable - log update-check + apk-download hits (#96)
The OTA was invisible server-side: /api/update/check and /download/apk returned without
logging, which is part of why the 1.9.0 auto-relaunch failure went unseen. Log every
version check (client version vs latest, update_available, whether an APK is staged) and
every APK download (a device actually applying an OTA), keyed on the CF-aware getClientIp
so production logs show the real per-device IP behind Cloudflare, not the edge.

Observability for the #96 auto-relaunch work (this is how we'll watch the OTA fire during
the relaunch testing). Part of #96.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:34:29 -05:00
ScreenTinker f06a87f4be fix(api): harden device pairing against brute-force (#87)
The 6-digit pairing code is generated client-side, so the server can't raise its entropy
without a player change. Instead, harden server-side (no client change):
- lib/pair-lockout.js: lock an IP out of POST /api/provision/pair after 5 failed claims
  (15-min lockout), and expire stale provisioning codes after 15 min so a code is not
  claimable indefinitely. A successful claim resets the IP.
- /pair enforces both. Only an UNKNOWN code (404) counts toward the lockout (a real guess);
  an EXPIRED code (410) is a legitimate-but-stale code and does NOT count, so a slow bulk
  rollout from one shared-NAT IP can't lock itself out. getClientIp is Cloudflare-aware
  (CF-Connecting-IP validated against a trusted edge peer), so the lockout keys on the real
  per-client IP, never a shared edge.

Unit-tested deterministically with injected time, incl. the bulk-rollout-never-locks case.

Closes #87

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:16:12 -05:00
ScreenTinker 3305e79e61 fix(api): consolidate device pairing to /pair, remove vestigial bare endpoint (#90)
POST /api/provision was a second pairing endpoint that paired a device by code but,
unlike POST /api/provision/pair, did NOT assign a workspace, enforce checkDeviceLimit, or
emit device:paired / dashboard:device-added - a silently-diverging duplicate that no
client ever called. It now returns 410 Gone and points callers at /pair, so
/api/provision/pair is the single, fully-protected pairing endpoint. The mount stays in
the JWT-only partition, so a Bearer st_ token still gets 401 (requireAuth) before the 410.

Closes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:13:16 -05:00
ScreenTinker 538f4a7b03 test(api): close #92 follow-up coverage gaps
The non-security gaps named in the public-API self-review:
- gap-fix: zone_id (playlist items) + layout_id (device PUT) accepted and returned on read,
  INCLUDING the cross-tenant rejection (the is_template OR workspace_id guard - the
  security-relevant one).
- docs serving: /openapi.yaml serves the spec, /docs returns the Redoc page.
- i18n drift-guard: apitoken.* keys have full parity across en/es/fr/de/pt (a key missing
  in one locale fails CI).
- token lifecycle branches: token-create workspace-membership validation and last_used_at
  stamping (integration), plus the must_change_password gate (unit test via the in-memory
  DB injection - cross-process WAL visibility is unreliable for that branch in-process).

119 tests total (was 108), all in the existing node --test job.

Closes #92

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:10:36 -05:00
ScreenTinker 33eaef826c test(api): fix spec scope drift + guard it in CI; Redoc provenance
Self-review follow-ups, kept as a separate commit so the review trail is honest.

- Spec drift: POST /widgets/preview was documented scope 'read' but the method-based
  tokenScopeGate enforces 'write' for any POST, so a read-token integrator following the
  published docs would hit a surprise 403. The code is right; fix the SPEC to match it.
- Guard it forever: test/openapi-contract.test.js cross-checks every spec operation's
  x-required-scope against the enforcement rule, and that every documented path is a
  public (token-reachable) router - both derived from the same config/api-surface.js.
  Adds js-yaml (devDep) to parse the spec. Spec/enforcement drift now fails CI.
- Vendored Redoc: add frontend/vendor/README.md (library, version 2.3.9, source, update
  steps) and drop the dangling //# sourceMappingURL line so /docs doesn't 404 in devtools.

Remaining (non-security) test-coverage gaps tracked in #92.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 2ad9f54b8e test(api): token partition + threat-model + device WS coverage
A dedicated public-API suite (boots the real server as a subprocess) so CI green proves
the token layer, not just the pre-existing tests:

- Partition firewall, derived from the SAME config/api-surface.js server.js mounts from:
  every JWT-only router 401s a token; a public-surface snapshot fails if any router is
  added to the token door; known-privileged routers asserted JWT-only.
- Threat model: role-strip gates, workspace-binding both directions (token ignores
  X-Workspace-Id, JWT honors it), the scope ladder, the render bypass, token lifecycle,
  and JWT no-regression.
- Device WS round-trip via socket.io-client (added as a devDep): valid device_token
  registers + receives its playlist; wrong token rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker c1b9c27f3a docs(api): OpenAPI spec, Redoc at /docs, CI spec-lint
- docs/openapi.yaml: the public, token-reachable surface only, with the auth model
  (Bearer st_) and a per-operation x-required-scope (read<write<full). JWT-only routers
  are excluded by design.
- Serve /openapi.yaml + /docs (Redoc via a vendored standalone bundle, no CDN so it
  works air-gapped; /docs is CSP-exempt). docs/ is bundled into the release tarball.
- CI: redocly lint + a public-only guard that fails loudly if a JWT-only path ever leaks
  into the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker dce0d22763 fix(api): expose zone_id + layout_id on the public write paths
- playlists: accept zone_id on item create + update, validated against a template or a
  layout in the playlist's workspace (no cross-tenant zone reference).
- devices: accept layout_id on PUT /api/devices/:id (symmetry with the layouts route),
  validated the same way; null clears it. Both are already returned in the GET SELECTs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker fab4ae909a feat(api): token management endpoints + Settings UI
- routes/tokens.js: create (returns the full secret once), list (never the secret),
  revoke. Mounted JWT-only via api-surface.js so an API token can never mint, list or
  revoke tokens - no self-escalation.
- Settings "API Tokens" section: create form (name + read/write/full scope), one-time
  secret reveal with copy, token list, revoke; i18n across en/es/fr/de/pt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 73ca3cf258 feat(api): scoped API token foundation + secure-by-exclusion mounts
Introduce the public API's token layer and make the router partition data-driven.

- api_tokens table: SHA-256 hashed secret, st_ prefix, workspace-bound, read/write/full scope.
- middleware/apiToken.js: bearerAuth front door (Bearer st_ -> token auth, else the
  unchanged requireAuth); apiTokenAuth acts as the owner with platform powers stripped
  to 'user' and the workspace binding made authoritative (X-Workspace-Id ignored);
  tokenScopeGate (read=GET, write=mutations) + requireScope('full') for commands.
- config/api-surface.js: single source of truth for the PUBLIC (token front door) vs
  JWT-ONLY (requireAuth) router partition. server.js mounts from these lists so the
  mount list and the partition firewall test cannot drift.
- device-groups: operational group commands (reboot/shutdown) require the full scope.

A Bearer st_ token fails jwt.verify on the JWT-only routers (401), so privileged
surfaces (admin, workspaces, ai, provision, white-label) are unreachable by exclusion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:45:09 -05:00
ScreenTinker 300d331562 fix(security): rate-limit the whole /api/provision pairing surface (#88)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
POST /api/provision (the routes/provisioning.js router endpoint) pairs a device
by pairing_code with no rate limit - the limit at server.js:287 was bound only to
the /api/provision/pair override. An authenticated user could brute-force 6-digit
pairing codes against the bare endpoint to claim devices in the unclaimed pool.
Bind the rate limit to the /api/provision mount so it covers both pairing paths.

Verified: 6 rapid POSTs to /api/provision now 429 on the 6th (was unlimited);
/api/provision/pair still 429s on the 6th.

Closes #88

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:46:13 -05:00
ScreenTinker 11e339dd89 ci(release): make the pipeline prerelease-aware (#80)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Tagging a pre-release (e.g. v1.9.0-rc1) was unsafe. Four fixes:

1. bump-version.sh writes a numeric-only x.y.z to tizen/config.xml (strips a
   -rc1/-beta.N suffix) so the .wgt still signs/installs; the full VERSION
   (with the suffix) still drives server/Android/package.json.
2. release.yml flags the GitHub Release --prerelease for a -suffix version
   (keeps it off "Latest" and out of the /releases/latest API).
3. release.yml moves docker :latest only for final releases - a pre-release no
   longer repoints :latest onto untested code.
4. upgrade.sh excludes pre-release tags from its default selection - GNU
   `sort -V` ranks 1.9.0-rc1 above the final 1.9.0, so the unfiltered default
   would silently pick an RC (which then auto-OTAs to field kiosks). An explicit
   `upgrade.sh v1.9.0-rc1` still works.

Verified the strip, tag selection, prerelease/tags logic, and YAML validity in
isolation.

Closes #80

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:53:39 -05:00
ScreenTinker bd732f4c48 fix(android): zone image falls back to server URL when not cached (#78)
A multi-zone layout's zone rendered its image from the local content cache
only. If the content wasn't cached yet at first render (first-sync download
still in flight, or the preloader hadn't fetched that zone's content), the
zone drew blank - and a static (single, unscheduled) zone has no rotation
timer to redraw, so it stayed blank until the app was restarted.

Mirror the video branch: when getCachedFile returns null, load the image
straight from the server (the item's remote_url, else /api/content/<id>/file)
instead of leaving the zone blank.

Verified live on a 2-zone layout with two single-unscheduled items and fresh
content: both zones render with no restart, with only one item actually in
the on-device cache (the other displayed via the URL fallback).

Closes #78

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:40:23 -05:00
ScreenTinker 68367cb3a3 fix(settings): show the real app version in the About section (#83)
The settings "About" section hardcoded "ScreenTinker v1.4.1", so it never
reflected the running build (#/admin already showed the correct version).
Fetch /api/version in the async settings render — the same unauthenticated
endpoint the admin view uses — and render it (blank-safe on fetch failure).

Closes #83

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:12:39 -05:00
ScreenTinker 4b688fcfb1 chore(release): v1.9.0
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
2026-06-11 21:11:31 -05:00
ScreenTinker ba8a71c4f5 docs(changelog): finalize 1.9.0 release notes 2026-06-11 21:11:30 -05:00
ScreenTinker 22376710ee fix(android): re-sign release APK with v1 (JAR) signature for MDM signage (#81)
minSdk 26 makes AGP default the v1 (JAR) signature off, so the release APK is
v2-only. Some MDM-managed commercial signage (MAXHUB via the Pivot MDM) silently
removes a v2-only app on the next reboot because its boot integrity check expects
a v1 signature — screens that power-cycle nightly lose the app and fall back to
the setup screen.

`enableV1Signing = true` has no effect at minSdk >= 24 (verified: still v2-only).
Instead, finalize assembleRelease with a `resignReleaseV1` task that re-signs via
apksigner with --v1-signing-enabled true and a low --min-sdk-version, emitting v1
alongside v2/v3. Verified: v1+v2+v3 at min-sdk 19, verifies at API 36, and the
re-signed APK installs and runs on a live API 36 emulator.

Closes #81

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:10:04 -05:00
ScreenTinker 3ddc209d19 docs(readme): add Wise donation link to Support section
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:37:07 -05:00
ScreenTinker 2ccf3264a9 feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.

- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
  DST US+AU, overnight-wrap anchoring, timezone correctness, date
  boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
  Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
  test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
  own clock, idle with a "Nothing scheduled" message + 30s re-check when
  everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
  client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
  in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
  to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
  guarantee, and the scheduled-single-video re-render tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00
ScreenTinker c8e664e66c fix(ws): guard fingerprint insert against stale device_id (FK violation noise)
A socket reconnecting with a device_id that no longer exists in `devices`
(e.g. the row was deleted server-side) hit the device_fingerprints insert
with an unknown foreign key. INSERT OR IGNORE does NOT suppress FOREIGN KEY
violations, so it threw a caught-but-noisy "Fingerprint tracking error" on
every such reconnect. Null out an unknown device_id before the insert; a
genuinely fresh device sends no device_id and was always fine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:46:41 -05:00
268 changed files with 22829 additions and 494 deletions

View file

@ -11,6 +11,12 @@
# instance never emits mail from a domain that isn't yours. # instance never emits mail from a domain that isn't yours.
SELF_HOSTED=true SELF_HOSTED=true
# Hide the Subscription/billing UI (nav item + pricing cards) and bounce #/billing to
# the dashboard. Opt-in; default off (billing shown). For instances that bill customers
# externally and don't sell plans through the app. UI-only — does not change SELF_HOSTED
# or disable any /api/subscription endpoints.
HIDE_BILLING=true
# Close public self-service registration — for instances where all accounts are # Close public self-service registration — for instances where all accounts are
# provisioned by your team (admin "Add user" / invites). When true, the public # provisioned by your team (admin "Add user" / invites). When true, the public
# signup route is blocked (OAuth auto-signup with it) AND the login page hides # signup route is blocked (OAuth auto-signup with it) AND the login page hides

View file

@ -33,6 +33,44 @@ jobs:
- run: npm ci - run: npm ci
- run: npm test - run: npm test
openapi:
name: OpenAPI spec lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
- name: Lint the public API spec
run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
# Contract integrity: the spec documents ONLY the token-reachable public surface.
# A JWT-only router (admin/auth/provision/...) appearing here is a security flag,
# not a convenience - fail loudly. (The runtime partition test is a separate suite
# that will cross-check the spec against the live mount list.)
- name: Assert spec is public-only
run: |
BAD=$(grep -oE '^ /(admin|auth|workspaces|ai|provision|white-label|status|subscription|stripe|teams|player-debug|contact|tokens)\b' docs/openapi.yaml || true)
if [ -n "$BAD" ]; then echo "::error::JWT-only path(s) leaked into the public spec:"; echo "$BAD"; exit 1; fi
if grep -qE 'unassigned|/prune' docs/openapi.yaml; then echo "::error::token-denied endpoint present in public spec"; exit 1; fi
echo "OK: spec is public-only"
android-test:
name: Android unit tests (Kotlin schedule evaluator vectors)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
# ScheduleEvalTest reads the SHARED shared/schedule-vectors.json (wired via
# the test task in app/build.gradle.kts), so a ScheduleEval.kt change that
# breaks the contract fails here.
- name: Kotlin evaluator vector conformance
working-directory: android
run: ./gradlew :app:testDebugUnitTest --no-daemon
smoke: smoke:
name: Boot smoke + version check name: Boot smoke + version check
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -62,11 +62,15 @@ jobs:
- name: Resolve version + previous tag - name: Resolve version + previous tag
id: ver id: ver
run: | run: |
echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" VERSION="$(cat VERSION)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
PREV="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)" PREV="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
echo "prev=$PREV" >> "$GITHUB_OUTPUT" echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Releasing ${GITHUB_REF_NAME} (version $(cat VERSION)); previous tag: ${PREV:-<none>}" # #80: a version carrying a -suffix (e.g. 1.9.0-rc1) is a pre-release.
case "$VERSION" in *-*) PRE=true ;; *) PRE=false ;; esac
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing ${GITHUB_REF_NAME} (version $VERSION, prerelease=$PRE); previous tag: ${PREV:-<none>}"
- name: Build Tizen .wgt (unsigned in CI) - name: Build Tizen .wgt (unsigned in CI)
run: | run: |
@ -83,7 +87,7 @@ jobs:
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \ --exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \ --exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
--exclude='*.apk' \ --exclude='*.apk' \
server frontend scripts VERSION README.md LICENSE .env.example ScreenTinker.wgt server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
echo "TARBALL=$OUT" >> "$GITHUB_ENV" echo "TARBALL=$OUT" >> "$GITHUB_ENV"
ls -la "$OUT" ls -la "$OUT"
@ -107,7 +111,11 @@ jobs:
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes" echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher" echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
echo " at \`https://<your-instance>/player\` (no signing needed)." echo " at \`https://<your-instance>/player\` (no signing needed)."
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)." if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
else
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
fi
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)." echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
} > RELEASE_NOTES.md } > RELEASE_NOTES.md
cat RELEASE_NOTES.md cat RELEASE_NOTES.md
@ -116,7 +124,12 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
# #80: pre-release tags publish as a GitHub *pre-release* (not "Latest"),
# which also keeps the /releases/latest API pointing at the last stable.
PRERELEASE_FLAG=""
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && PRERELEASE_FLAG="--prerelease"
gh release create "${{ steps.ver.outputs.tag }}" \ gh release create "${{ steps.ver.outputs.tag }}" \
$PRERELEASE_FLAG \
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \ --title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
--notes-file RELEASE_NOTES.md \ --notes-file RELEASE_NOTES.md \
"${TARBALL}" \ "${TARBALL}" \
@ -129,7 +142,17 @@ jobs:
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- id: ver - id: ver
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT" run: |
VERSION="$(cat VERSION)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# #80: move :latest only for final releases - a pre-release (1.9.0-rc1) must
# not repoint :latest onto untested code (anyone on :latest pulls it on restart).
TAGS="ghcr.io/screentinker/screentinker:$VERSION"
case "$VERSION" in
*-*) echo "Pre-release $VERSION: :latest will NOT be moved" ;;
*) TAGS="${TAGS}"$'\n'"ghcr.io/screentinker/screentinker:latest" ;;
esac
{ echo "tags<<__EOF__"; printf '%s\n' "$TAGS"; echo "__EOF__"; } >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
@ -142,9 +165,7 @@ jobs:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: ${{ steps.ver.outputs.tags }}
ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}
ghcr.io/screentinker/screentinker:latest
# TODO (deferred): build + sign the Android APK in CI. Requires the release # TODO (deferred): build + sign the Android APK in CI. Requires the release
# keystore + passwords as encrypted Actions secrets. For now the maintainer # keystore + passwords as encrypted Actions secrets. For now the maintainer

136
CHANGELOG.md Normal file
View file

@ -0,0 +1,136 @@
# Changelog
## 1.9.2-beta1 — unreleased
### Fixed — server resilience (#142)
- **A single flapping device can no longer saturate the event loop.** A new
load-aware, per-device reconnect throttle (`lib/reconnect-throttle.js`) gates
genuine reconnects *before* the heavy register work (DB writes + playlist build).
The verdict is per-device; global event-loop lag only multiplies an
already-flagged device's backoff and never throttles a healthy one. Hard ceiling
+ cold-start warm-up so a full-fleet reconnect after a deploy is never throttled.
- **`device_status_log` growth is bounded.** Added
`idx_device_status_log_device_ts`, a global retention sweep (`pruneStatusLog`,
`STATUS_LOG_RETENTION_DAYS` default 3) covering removed/idle devices and the
`offline_timeout` path, and de-duplicated the table's `CREATE TABLE`.
- **`content-ack` spam de-duplicated.** Repeated identical
`(device_id, content_id, status)` reports are suppressed within
`CONTENT_ACK_DEDUP_MS` (default 10s).
- **Provisioning cleanup window corrected.** Unclaimed provisioning devices are now
swept after 24h (the code used `365 * 86400` — a year — contradicting its own
comment).
### Added — observability (#142)
- **Event-loop lag telemetry** via `perf_hooks.monitorEventLoopDelay()`. Sampled to
a bounded `event_loop_lag` table (indexed + pruned, `LAG_TELEMETRY_RETENTION_DAYS`)
and surfaced on `/api/status` as `loop_lag` (mean/p50/p99/max + band).
### Maintenance
- Operators whose `device_status_log` is already bloated from a pre-1.9.2 deployment
should reclaim disk with a **one-time manual `VACUUM`** in a maintenance window;
retention now bounds further growth. Auto-VACUUM is intentionally not enabled.
See [`docs/maintenance-device-status-log.md`](docs/maintenance-device-status-log.md).
## 1.9.1-beta3 — unreleased
### Fixed — Tizen player
- **#118 Sticky "Not authenticated" banner.** On TV sleep/wake the socket reconnects and
a heartbeat could fire on the fresh, not-yet-registered socket; the server rejected it
with `device:auth-error`, which the player showed as a *sticky* toast over still-playing
content (and, worse, dropped its saved credentials and re-paired). Heartbeats are now
gated on a per-connection `authenticated` flag (set only between `device:registered` and
`disconnect`/`auth-error`), the heartbeat timer is stopped on `connect`/`disconnect`/
`auth-error`, the stale banner is cleared on `device:registered`, and the `auth-error`
toast is non-sticky so any transient case self-clears.
- **#119 `app_version` stuck at `1.0.0`.** The hardcoded constant made every Tizen device
report `1.0.0` regardless of the installed `.wgt`. The version now resolves at runtime
from `config.xml` via the Tizen application API, with a fallback constant that
`build-wgt.sh` stamps from `config.xml`'s `version=""`.
### Added — Tizen player
- **Video walls (`wall:sync`).** The Tizen player now supports wall membership: when the
payload carries `wall_config`, a new `WallController` positions the stage (vw/vh) as this
screen's slice of the wall and drives the single-zone player as leader or follower. The
leader broadcasts `wall:sync` at 4Hz; followers align their index and keep their video
locked to the leader's clock with a latency-compensated drift controller (hard-seek past
0.3s, gentle ±3% playbackRate nudge past 0.05s), and request an immediate position on
(re)connect via `wall:sync-request`. Mirrors the web player (the Android player has no
wall support). Per-tile `rotation` is not applied yet (web-player parity). Wall emits are
gated on auth + connection so a pre-register tick can't trip `device:auth-error`.
- **Multi-zone layouts (Android parity).** The Tizen player now renders assigned layouts,
not just fullscreen single-zone. A new `ZoneRenderer` (ports the Android `ZoneManager`)
positions zones by percent geometry with `z_index`/`fit_mode`/background, groups
assignments by `zone_id` (unassigned content goes to the first zone), and rotates each
zone independently with the same per-item schedule gating (#74/#75). `app.js` selects the
renderer from `payload.layout`; single-zone playback is unchanged. (Video walls
`wall:sync` are still Android-only.)
- **#121 Remote commands.** Added a `device:command` handler (`refresh`, `launch`,
`screen_on`, `screen_off`, plus honest no-op toasts for `update`/`reboot`/`shutdown`,
which need B2B/MDM privileges a sideloaded app lacks). Removed the dead `device:reload`
listener (the server never emitted it) in favour of `device:command` `refresh`.
- **#120 Dashboard preview.** Added `device:screenshot-request` / `device:remote-start` /
`device:remote-stop`. Images capture for real; `<video>`/YouTube fall back to a status
card because the TV's hardware video plane and cross-origin iframes can't be read into a
`<canvas>`. See `tizen/README.md` for the support matrix.
- **#122 Updates / boot.** Documented the supported paths — `.wgt` re-sideload or URL
Launcher/MDM refresh for updates, and display-level kiosk/URL-Launcher settings for
auto-launch on boot (there is no in-app OTA or `config.xml` autostart for a sideloaded
consumer TV web app).
## 1.9.0 — 2026-06-11
### Added
- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule
blocks — active days, a start/end time-of-day, and optional start/end dates. An item
plays when the screen's local "now" matches at least one block; an item with no
blocks always plays. Edit per item via the clock icon in the playlist editor (a badge
summarises the schedule on each row).
- **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows
that cross midnight (a Fri 22:0002:00 block is active Sat 01:00).
- **#75 auto-expire:** inclusive start/end dates; an item past its end date stops
showing automatically — even on offline screens, because evaluation is on-device.
- All three players (web, Android, Tizen) evaluate schedules client-side against their
own clock, so dayparting and expiry work offline. They share one evaluator contract,
`shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU),
overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the
vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen
copy is byte-identical to the JS source and checked under node.
- Device detail now shows the screen's reported timezone and clock, with a **clock-skew
warning** when the device clock differs from the server by more than 2 minutes (a bad
device clock makes schedules fire at the wrong local time).
### Changed — device-level schedule timezone (behaviour change)
- Device/group **schedule overrides** (the existing calendar feature) are now evaluated
in each device's effective timezone instead of the server's local time. Previously the
`schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00.
Now "07:00" means the *screen's* 07:00 — which is what was intended.
- **Who is affected:** self-hosters whose server timezone differs from their screens'
timezone — their existing device schedules will shift to fire at the screens' local
time. Single-timezone deployments (server and screens in the same zone) are
unaffected. A device with no timezone set and not reporting one falls back to the
server clock (unchanged from before).
### Fixed
- **#81 — release APK is now v1 + v2 + v3 signed.** With `minSdk 26`, the Android Gradle
Plugin defaulted the v1 (JAR) signature *off*, producing a v2-only APK that some
MDM-managed commercial signage (e.g. MAXHUB via the Pivot MDM) silently removes on the
next reboot — so screens that power-cycle nightly lost the app and fell back to the
setup screen. Setting `enableV1Signing = true` had no effect at minSdk ≥ 24; the release
build now re-signs with `apksigner` and a low `--min-sdk-version` to emit the JAR
signature alongside v2/v3. Verified to install and run on Android 14+/API 36 as well.
### Notes
- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id,
malformed block), the item **plays** rather than being hidden. A blank screen is worse
than an over-running promo — this is a guarantee, enforced in all three players.
- Windows are enforced at **item boundaries**: a long item finishes before the schedule
is re-checked, so it can overshoot its window by up to its own duration.
- **A single video *with a schedule* now re-renders at each loop boundary** so its window
can be re-evaluated; seamless native looping still applies to unscheduled single videos.
Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for
its daypart/expiry actually being honoured.
- **Re-publish required:** editing a schedule puts the playlist into draft; publish to
push schedules to devices. Existing published playlists keep playing unchanged until
re-published.
- Players that predate this release ignore the new fields and keep playing everything
(graceful degradation) — update players to honour schedules.

View file

@ -30,6 +30,9 @@ COPY server/ /app/server/
COPY --from=builder /app/server/node_modules /app/server/node_modules COPY --from=builder /app/server/node_modules /app/server/node_modules
COPY frontend/ /app/frontend/ COPY frontend/ /app/frontend/
COPY VERSION /app/VERSION COPY VERSION /app/VERSION
# the /openapi.yaml route serves ../docs/openapi.yaml (the spec Redoc on /docs fetches);
# without this it 404s in the image even though it serves fine from a dev checkout.
COPY docs/openapi.yaml /app/docs/openapi.yaml
# database.js requires scripts/migrate-multitenancy at boot # database.js requires scripts/migrate-multitenancy at boot
COPY scripts/ /app/scripts/ COPY scripts/ /app/scripts/
VOLUME ["/data"] VOLUME ["/data"]

3
Examples/PIP-Air-Quality/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,89 @@
# PiP Air-Quality Widget
A persistent corner **air-quality widget** for ScreenTinker screens, driven by the
**[Open-Meteo Air Quality API](https://open-meteo.com/en/docs/air-quality-api)** — no API key,
no signup. Shows the current **US AQI** (color-coded by EPA band) plus the component
pollutants (PM2.5 / PM10 / O₃ / NO₂) and refreshes itself in place.
```
Open-Meteo Air Quality ──poll──▶ aqi.js ──POST /api/pip──▶ ScreenTinker ──ws──▶ player
(us_aqi, pm2.5, …) (normalise + color) (web overlay) (corner widget)
```
It pushes a `type: web` overlay with `duration: 0` (stays up until cleared) and re-pushes
each poll; the player keeps a single overlay slot (last-show-wins) so the widget just updates.
On `Ctrl-C` it clears the overlay.
## How it works
- **`aqi.js`** — polls Open-Meteo, normalises the response, maps the US AQI to an EPA category
+ color, and pushes/refreshes the overlay. Pure helpers (`aqiCategory`, `normalise`,
`aqiUrl`, `overlayUri`) are exported for the test.
- **`aqi-overlay.html` + `aqi-overlay.js`** — the overlay page rendered in the player's iframe.
All data comes from the URL query string; the JS is external (no inline script) so it passes
the signage server's CSP (`scriptSrc 'self'`).
### US EPA AQI bands
| US AQI | Category | Color |
|---|---|---|
| 050 | Good | `#1f9d55` |
| 51100 | Moderate | `#F2C200` |
| 101150 | Unhealthy (Sensitive) | `#E8730C` |
| 151200 | Unhealthy | `#CC0000` |
| 201300 | Very Unhealthy | `#7B0000` |
| 301+ | Hazardous | `#5B0000` |
## Setup
1. **Host the overlay page.** Copy both `aqi-overlay.html` and `aqi-overlay.js` into your
signage server's frontend directory so they're served same-origin as the player (required by
the CSP). They'll be reachable at `https://<your-server>/aqi-overlay.html`.
2. **Get a `full`-scope API token** (`st_…`) from the dashboard.
3. **Configure.** Copy `config.example.json``config.json` and fill in:
- `api_base` — your ScreenTinker server, e.g. `https://signage.example.com`
- `api_token` — the `st_…` token
- `overlay_base_url``https://<your-server>/aqi-overlay.html`
- `device_id` — a device **or** group id
- `lat` / `lon` / `location_name` — the location to report
- optional: `poll_interval_sec` (default 900), `position` (default `top-right`),
`width`/`height`, `border_radius`
4. **Run:**
```bash
node aqi.js
```
Leave it running; it refreshes every `poll_interval_sec`. `Ctrl-C` clears the overlay.
## Test (offline, no network)
```bash
npm test
```
Checks the EPA band boundaries, the category→color map, and the normaliser against
`fixture-aqi.json`. Prints `RESULT: PASS ✅`.
## Local quick-start (this machine)
The local dev instance serves the player over self-signed HTTPS, so disable TLS verification:
```bash
# 1. copy the overlay assets into the local server's frontend dir, e.g.:
cp aqi-overlay.html aqi-overlay.js /home/owner/Downloads/remote_display/frontend/
# 2. config.json for the local "testing" player:
# api_base https://localhost:3443/
# api_token st_REPLACE_WITH_A_FULL_SCOPE_TOKEN
# overlay_base_url https://localhost:3443/aqi-overlay.html
# device_id DEVICE_OR_GROUP_ID
NODE_TLS_REJECT_UNAUTHORIZED=0 node aqi.js
```
## Notes
- Open-Meteo's `us_aqi` is the **overall** US AQI (max of the per-pollutant sub-indices).
- The free Open-Meteo API is rate-limited; a 900s (15 min) poll is plenty for air quality.
- `config.json` is gitignored (it holds your token).

View file

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Air Quality</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45);
padding: 14px 18px; box-sizing: border-box; border-left: 8px solid #888; }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.loc { font-size: clamp(13px, 3.2vw, 18px); font-weight: 600; opacity: .92;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { font-size: clamp(11px, 2.6vw, 15px); font-weight: 800; text-transform: uppercase;
letter-spacing: .03em; padding: 3px 9px; border-radius: 999px; color: #111; white-space: nowrap; }
.mid { display: flex; align-items: baseline; gap: 10px; margin-top: 2px; }
.aqi { font-size: clamp(40px, 13vw, 68px); font-weight: 800; line-height: 1; }
.aqilabel { font-size: clamp(11px, 2.6vw, 14px); font-weight: 700; opacity: .7; }
.cat { font-size: clamp(13px, 3.2vw, 18px); font-weight: 700; }
.grid { margin-top: auto; display: flex; flex-wrap: wrap; gap: 4px 16px;
font-size: clamp(11px, 2.4vw, 14px); opacity: .85; padding-top: 8px; }
.grid b { font-weight: 700; opacity: 1; }
.updated { opacity: .7; }
</style>
</head>
<body>
<div class="card" id="card">
<div class="top">
<span class="loc" id="loc"></span>
<span class="badge" id="badge"></span>
</div>
<div class="mid">
<span class="aqi" id="aqi"></span>
<span class="aqilabel">US AQI</span>
</div>
<div class="cat" id="cat"></div>
<div class="grid" id="grid"></div>
<div class="grid"><span class="updated" id="updated"></span></div>
</div>
<script src="aqi-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,38 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the air-quality fields from the URL query string and populates the widget.
(function () {
var q = new URLSearchParams(location.search);
var get = function (k) { return (q.get(k) || '').trim(); };
var set = function (id, txt) { var el = document.getElementById(id); if (el) el.textContent = txt; };
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || '888888');
set('loc', get('location') || 'Air Quality');
set('aqi', get('aqi') !== '' ? get('aqi') : '--');
set('cat', get('category') || '');
// Category color drives the AQI number, the left accent, and a pill badge.
document.getElementById('aqi').style.color = color;
document.getElementById('card').style.borderLeftColor = color;
var badge = document.getElementById('badge');
if (get('category')) { badge.textContent = get('category'); badge.style.background = color; }
var parts = [];
if (get('pm25') !== '') parts.push('<b>PM2.5</b> ' + esc(get('pm25')));
if (get('pm10') !== '') parts.push('<b>PM10</b> ' + esc(get('pm10')));
if (get('ozone') !== '') parts.push('<b>O₃</b> ' + esc(get('ozone')));
if (get('no2') !== '') parts.push('<b>NO₂</b> ' + esc(get('no2')));
document.getElementById('grid').innerHTML = parts.join('');
var updated = get('updated');
if (updated) {
var d = new Date(updated);
set('updated', isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));
}
function esc(s) {
return s.replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
})();

View file

@ -0,0 +1,149 @@
'use strict';
// Open-Meteo Air Quality -> ScreenTinker PiP air-quality widget.
//
// Polls air-quality-api.open-meteo.com (NO API KEY) for the current US AQI plus the
// component pollutants, and pushes a small persistent web overlay to a screen (or group).
// Re-pushes on each poll; the player keeps a single overlay slot (last-show-wins), so the
// widget updates in place. Pushed with duration 0 (stays until cleared). Clears on exit.
//
// node aqi.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
// US EPA AQI bands -> { label, color }. Boundaries are inclusive of the upper value
// (0-50 Good, 51-100 Moderate, ...). 301+ is Hazardous.
function aqiCategory(aqi) {
const n = Number(aqi);
if (!Number.isFinite(n)) return { label: 'Unknown', color: '#888888' };
if (n <= 50) return { label: 'Good', color: '#1f9d55' };
if (n <= 100) return { label: 'Moderate', color: '#F2C200' };
if (n <= 150) return { label: 'Unhealthy (Sensitive)', color: '#E8730C' };
if (n <= 200) return { label: 'Unhealthy', color: '#CC0000' };
if (n <= 300) return { label: 'Very Unhealthy', color: '#7B0000' };
return { label: 'Hazardous', color: '#5B0000' };
}
// Pure normaliser: Open-Meteo air-quality JSON -> the overlay's display view.
function normalise(data, cfg = {}) {
const cur = (data && data.current) || {};
const round = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v)));
const usAqi = round(cur.us_aqi);
const cat = aqiCategory(usAqi);
return {
location: cfg.location_name || 'Air Quality',
usAqi,
category: cat.label,
color: cat.color,
pm25: round(cur.pm2_5),
pm10: round(cur.pm10),
ozone: round(cur.ozone),
no2: round(cur.nitrogen_dioxide),
updated: cur.time || '',
};
}
function aqiUrl(cfg) {
const q = new URLSearchParams({
latitude: String(cfg.lat),
longitude: String(cfg.lon),
current: 'us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide',
timezone: 'auto',
});
return `https://air-quality-api.open-meteo.com/v1/air-quality?${q.toString()}`;
}
function overlayUri(base, view) {
const q = new URLSearchParams({
location: view.location || '',
aqi: view.usAqi == null ? '' : String(view.usAqi),
category: view.category || '',
color: (view.color || '#888888').replace(/[^0-9a-fA-F]/g, ''),
pm25: view.pm25 == null ? '' : String(view.pm25),
pm10: view.pm10 == null ? '' : String(view.pm10),
ozone: view.ozone == null ? '' : String(view.ozone),
no2: view.no2 == null ? '' : String(view.no2),
updated: view.updated || '',
});
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
module.exports = { aqiCategory, normalise, aqiUrl, overlayUri };
// ---- live runner (skipped when this file is require()'d by the test) ----
if (require.main === module) {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const POLL_SEC = cfg.poll_interval_sec || 900;
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE || cfg.lat == null || cfg.lon == null) {
console.error('config must set api_base, api_token, overlay_base_url, device_id, lat, lon.');
process.exit(1);
}
let pipId = null;
async function pipShow(view) {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, view),
position: cfg.position || 'top-right',
width: cfg.width || 360, height: cfg.height || 200,
duration: 0, opacity: cfg.opacity != null ? cfg.opacity : 1,
border_radius: cfg.border_radius != null ? cfg.border_radius : 16,
close_button: false,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear() {
if (!pipId) return;
await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
}).catch(() => {});
}
async function tick() {
try {
const res = await fetch(aqiUrl(cfg), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`Open-Meteo HTTP ${res.status}`);
const view = normalise(await res.json(), cfg);
pipId = await pipShow(view);
console.log(`[${new Date().toISOString()}] ${view.location}: AQI ${view.usAqi} (${view.category}) pm2.5=${view.pm25} pm10=${view.pm10} pip=${pipId}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] update error: ${e.message}`);
}
}
async function main() {
console.log(`Air-Quality PiP widget — ${cfg.location_name || `${cfg.lat},${cfg.lon}`}, every ${POLL_SEC}s, ${cfg.position || 'top-right'}`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
console.log('\nclearing overlay before exit...');
await pipClear();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
}

View file

@ -0,0 +1,16 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/aqi-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"lat": 45.5152,
"lon": -122.6784,
"location_name": "Portland, OR",
"poll_interval_sec": 900,
"position": "top-right",
"width": 360,
"height": 200,
"border_radius": 16
}

View file

@ -0,0 +1,21 @@
{
"latitude": 45.5,
"longitude": -122.5,
"timezone": "America/Los_Angeles",
"current_units": {
"time": "iso8601",
"us_aqi": "USAQI",
"pm2_5": "μg/m³",
"pm10": "μg/m³",
"ozone": "μg/m³",
"nitrogen_dioxide": "μg/m³"
},
"current": {
"time": "2026-06-18T10:00",
"us_aqi": 72,
"pm2_5": 23.4,
"pm10": 31.2,
"ozone": 88.0,
"nitrogen_dioxide": 12.4
}
}

View file

@ -0,0 +1,12 @@
{
"name": "pip-air-quality",
"version": "0.1.0",
"description": "Example: a persistent ScreenTinker PiP air-quality widget driven by the keyless Open-Meteo Air Quality API.",
"type": "commonjs",
"main": "aqi.js",
"scripts": {
"start": "node aqi.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,58 @@
'use strict';
// Offline test: US EPA AQI band boundaries + the Open-Meteo normaliser, against
// fixture-aqi.json. No network, no API token. Prints "RESULT: PASS ✅", exits 0 on success.
const fs = require('fs');
const a = require('./aqi');
const data = JSON.parse(fs.readFileSync('./fixture-aqi.json', 'utf8'));
const view = a.normalise(data, { location_name: 'Portland, OR' });
console.log('normalised view:');
console.log(view);
console.log('\n--- AQI band boundaries ---');
const bands = [
[0, 'Good'], [50, 'Good'], [51, 'Moderate'], [100, 'Moderate'],
[101, 'Unhealthy (Sensitive)'], [150, 'Unhealthy (Sensitive)'],
[151, 'Unhealthy'], [200, 'Unhealthy'],
[201, 'Very Unhealthy'], [300, 'Very Unhealthy'], [301, 'Hazardous'], [500, 'Hazardous'],
];
for (const [n, label] of bands) console.log(`${String(n).padStart(3)} -> ${a.aqiCategory(n).label}`);
const checks = {
'0 -> Good': a.aqiCategory(0).label === 'Good',
'50 -> Good (upper bound)': a.aqiCategory(50).label === 'Good',
'51 -> Moderate': a.aqiCategory(51).label === 'Moderate',
'100 -> Moderate (upper bound)': a.aqiCategory(100).label === 'Moderate',
'101 -> Unhealthy (Sensitive)': a.aqiCategory(101).label === 'Unhealthy (Sensitive)',
'150 -> Unhealthy (Sensitive) (upper bound)': a.aqiCategory(150).label === 'Unhealthy (Sensitive)',
'200 -> Unhealthy (upper bound)': a.aqiCategory(200).label === 'Unhealthy',
'201 -> Very Unhealthy': a.aqiCategory(201).label === 'Very Unhealthy',
'301 -> Hazardous': a.aqiCategory(301).label === 'Hazardous',
'Good color': a.aqiCategory(25).color === '#1f9d55',
'Moderate color': a.aqiCategory(72).color === '#F2C200',
'Hazardous color': a.aqiCategory(400).color === '#5B0000',
'unknown AQI falls back': a.aqiCategory(undefined).label === 'Unknown',
'usAqi from fixture': view.usAqi === 72,
'category from fixture': view.category === 'Moderate',
'color matches category': view.color === '#F2C200',
'pm25 rounded': view.pm25 === 23,
'pm10 rounded': view.pm10 === 31,
'ozone rounded': view.ozone === 88,
'no2 rounded': view.no2 === 12,
'location passthrough': view.location === 'Portland, OR',
'updated passthrough': view.updated === '2026-06-18T10:00',
};
console.log('\n--- assertions ---');
let ok = true;
for (const [name, pass] of Object.entries(checks)) {
console.log(`${pass ? '✓' : '✗'} ${name}`);
if (!pass) ok = false;
}
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,89 @@
# PiP Announce / Broadcast
Flash a one-off text announcement onto a ScreenTinker screen (or a whole group) using
the **PiP overlay API**, then clear it whenever you like. Good for fire drills, "back in
5 minutes", shift changes, a quick "Welcome, visitors!", or any manual broadcast.
It pushes a `web` overlay that renders a small dark card (optional coloured title band +
big message + a "posted" timestamp). The overlay page reads everything from its URL query
string, so there's no server-side state — the message lives entirely in the pushed URL.
## How it works
```
announce.js ──POST /api/pip──▶ server ──WS device:pip-show──▶ player
renders <iframe
src=message-overlay.html?title&message&color>
```
- `announce.js` builds an overlay URL from `overlay_base_url` + `?title&message&color` and
POSTs it to `/api/pip` (`type: "web"`).
- The player loads that URL in an iframe overlay. Because the player enforces a strict CSP
(`script-src 'self'`), the overlay HTML loads its JS via `<script src="message-overlay.js">`
(no inline scripts) and the JS reads the query string.
- `duration` controls auto-dismiss: `0` (default) keeps it up until you clear it; any
positive value (seconds) auto-clears on the player at that time.
## Setup
You need an `st_` API token with the **`full`** scope (PiP is fleet-affecting).
```bash
cp config.example.json config.json
# edit config.json: api_base, api_token, overlay_base_url, device_id
```
The overlay page is served by the signage server as a **same-origin** static file. Copy the
two overlay files into the server's frontend directory and point `overlay_base_url` at them:
```bash
# from the repo root, into the served frontend dir:
cp Examples/PIP-Announce-Broadcast/message-overlay.html frontend/
cp Examples/PIP-Announce-Broadcast/message-overlay.js frontend/
# then in config.json: "overlay_base_url": "https://<your-server>/message-overlay.html"
```
Same-origin matters: the player iframe and the overlay must share the server's origin so
the self-signed cert / CSP are honoured.
## Usage
```bash
# basic broadcast (stays until cleared)
node announce.js "Fire drill at 2:00 PM"
# with a coloured title band, auto-clear after 60s, centered
node announce.js "Back in 5 minutes" --title "AT LUNCH" --duration 60 --color "#E8730C" --position center
# target a specific device or a group (overrides config device_id)
node announce.js "All-hands in the atrium" --group <GROUP_ID>
# clear it
node announce.js --clear --device <DEVICE_ID> --pip <PIP_ID>
# (omit --pip to clear whatever overlay is showing)
```
Flags: `--title`, `--device`, `--group`, `--duration` (sec), `--color` (#RRGGBB),
`--position` (`top-right|top-left|bottom-right|bottom-left|center`), `--config`, `--clear`, `--pip`.
## Local quick-start (this dev box)
A web player is already running and paired:
- `api_base`: `https://localhost:3443/` (self-signed — prefix commands with `NODE_TLS_REJECT_UNAUTHORIZED=0`)
- `device_id`: `DEVICE_OR_GROUP_ID`
- token: `st_REPLACE_WITH_A_FULL_SCOPE_TOKEN`
```bash
cp Examples/PIP-Announce-Broadcast/message-overlay.html frontend/
cp Examples/PIP-Announce-Broadcast/message-overlay.js frontend/
cd Examples/PIP-Announce-Broadcast
# config.json with the values above and overlay_base_url=https://localhost:3443/message-overlay.html
NODE_TLS_REJECT_UNAUTHORIZED=0 node announce.js "Hello from PiP" --title TEST --duration 20
```
## Test
```bash
npm test # offline; exercises the URL builder and arg parser
```

View file

@ -0,0 +1,141 @@
'use strict';
// PIP-Announce-Broadcast — flash a one-off announcement onto a ScreenTinker screen
// or group via the PiP overlay API, then clear it on demand.
//
// node announce.js "Fire drill at 2:00 PM" [--title "NOTICE"]
// [--device <id> | --group <id>] [--duration 60] [--color "#CC0000"]
// [--position center] [--config config.json]
// node announce.js --clear [--device <id>] [--pip <pip_id>]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
const POSITIONS = ['top-right', 'top-left', 'bottom-right', 'bottom-left', 'center'];
// --- pure helpers (exported for the offline test) -------------------------
// Sanitise a colour to exactly 6 hex digits (no '#'); fall back to CC0000.
function sanitizeColor(c) {
const hex = String(c || '').replace(/[^0-9a-fA-F]/g, '');
return hex.length === 6 ? hex : 'CC0000';
}
// Build the overlay iframe URL: overlay_base_url + ?title&message&color.
// Color is sanitised to 6 hex; everything is URL-encoded by URLSearchParams.
function buildOverlayUri(base, { title = '', message = '', color = '' } = {}) {
const q = new URLSearchParams({
title: title || '',
message: message || '',
color: sanitizeColor(color),
});
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
// Minimal flag parser. First non-flag positional is the message.
function parseArgs(argv) {
const out = { _: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--clear') out.clear = true;
else if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) out[key] = true;
else { out[key] = next; i++; }
} else out._.push(a);
}
return out;
}
// --- runtime --------------------------------------------------------------
function loadConfig(p) {
const configPath = p || path.join(__dirname, 'config.json');
try {
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
console.error(`Could not read config at ${configPath}: ${e.message}`);
console.error('Copy config.example.json to config.json and fill it in.');
process.exit(1);
}
}
async function postJson(url, token, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
return { ok: res.ok, status: res.status, json };
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const cfg = loadConfig(args.config);
const apiBase = String(cfg.api_base || '').replace(/\/$/, '');
const token = cfg.api_token;
const target = args.device || args.group || cfg.device_id;
if (!apiBase || !token) { console.error('config must set api_base and api_token.'); process.exit(1); }
if (!target) { console.error('no target: pass --device/--group or set device_id in config.'); process.exit(1); }
if (args.clear) {
const body = { device_id: target };
if (args.pip && args.pip !== true) body.pip_id = args.pip;
const { ok, status, json } = await postJson(`${apiBase}/api/pip/clear`, token, body);
if (!ok) { console.error(`clear failed (${status}): ${json.error || 'unknown'}`); process.exit(1); }
console.log(`cleared on ${target} — sent=${json.sent} offline=${json.offline}`);
return;
}
const message = args._[0];
if (!message) {
console.error('usage: node announce.js "your message" [--title T] [--device ID|--group ID] [--duration N] [--color #RRGGBB] [--position P]');
process.exit(1);
}
const ov = cfg.overlay || {};
const position = args.position || ov.position || 'center';
if (!POSITIONS.includes(position)) { console.error(`invalid --position; use one of: ${POSITIONS.join(', ')}`); process.exit(1); }
const color = args.color || ov.color || '#CC0000';
const duration = args.duration != null ? Math.max(0, parseInt(args.duration, 10) || 0) : (ov.duration != null ? ov.duration : 0);
const overlayBase = cfg.overlay_base_url;
if (!overlayBase) { console.error('config must set overlay_base_url.'); process.exit(1); }
const uri = buildOverlayUri(overlayBase, {
title: (args.title && args.title !== true) ? args.title : (cfg.default_title || ''),
message,
color,
});
const body = {
device_id: target,
type: 'web',
uri,
position,
width: ov.width || 900,
height: ov.height || 300,
duration,
border_radius: ov.border_radius != null ? ov.border_radius : 16,
opacity: ov.opacity != null ? ov.opacity : 1,
close_button: false,
title: (args.title && args.title !== true) ? args.title : undefined,
};
const { ok, status, json } = await postJson(`${apiBase}/api/pip`, token, body);
if (!ok || !json.pip_id) { console.error(`show failed (${status}): ${json.error || 'unknown'}`); process.exit(1); }
console.log(`shown on ${target} (${json.target}) pip=${json.pip_id} dur=${duration || '∞'}s sent=${json.sent} offline=${json.offline}`);
console.log(`clear it with: node announce.js --clear --device ${target} --pip ${json.pip_id}`);
}
if (require.main === module) {
main().catch((e) => { console.error(e.message || e); process.exit(1); });
}
module.exports = { buildOverlayUri, sanitizeColor, parseArgs, POSITIONS };

View file

@ -0,0 +1,17 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/message-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"default_title": "NOTICE",
"overlay": {
"position": "center",
"width": 900,
"height": 300,
"border_radius": 16,
"color": "#CC0000",
"duration": 0
}
}

View file

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Announcement</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
.band { padding: 12px 22px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;
font-size: clamp(14px, 2.8vw, 22px); display: none; }
.band.show { display: block; }
.body { padding: 22px 26px; display: flex; flex-direction: column; gap: 12px; flex: 1; justify-content: center; }
.message { font-size: clamp(22px, 5.5vw, 44px); font-weight: 700; line-height: 1.18; }
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
</style>
</head>
<body>
<div class="card">
<div class="band" id="band"></div>
<div class="body">
<div class="message" id="message"></div>
<div class="footer"><span id="updated"></span></div>
</div>
</div>
<script src="message-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the announcement fields from the URL query string and populates the card.
(function () {
var q = new URLSearchParams(location.search);
var get = function (k) { return (q.get(k) || '').trim(); };
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
var title = get('title');
var band = document.getElementById('band');
if (title) {
band.textContent = title.toUpperCase();
band.style.background = color;
band.classList.add('show');
}
document.getElementById('message').textContent = get('message') || 'Announcement';
// Footer shows when the overlay was rendered, so a static announcement still
// reads as "current".
var now = new Date();
document.getElementById('updated').textContent = isNaN(now) ? '' : ('posted ' + now.toLocaleString());
})();

View file

@ -0,0 +1,12 @@
{
"name": "pip-announce-broadcast",
"version": "0.1.0",
"description": "Example: flash a one-off announcement onto a ScreenTinker screen or group via the PiP overlay API.",
"type": "commonjs",
"main": "announce.js",
"scripts": {
"start": "node announce.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,42 @@
'use strict';
// Offline test for the pure overlay-URI builder. No network, no config needed.
const { buildOverlayUri, sanitizeColor, parseArgs } = require('./announce');
let ok = true;
function check(name, cond) {
console.log(`${cond ? '✓' : '✗'} ${name}`);
if (!cond) ok = false;
}
// color sanitisation
check("sanitizeColor strips '#'", sanitizeColor('#CC0000') === 'CC0000');
check('sanitizeColor falls back on garbage', sanitizeColor('not-a-color') === 'CC0000');
check('sanitizeColor falls back on short hex', sanitizeColor('#FFF') === 'CC0000');
check('sanitizeColor keeps valid 6-hex', sanitizeColor('1a2b3c') === '1a2b3c');
// uri building + round-trip through URLSearchParams
const base = 'https://signage.example.com/message-overlay.html';
const msg = 'Fire drill at 2:00 PM — exit via Stairwell B & meet @ lot #3';
const uri = buildOverlayUri(base, { title: 'Notice!', message: msg, color: '#CC0000' });
const u = new URL(uri);
check('uri keeps the base path', u.pathname.endsWith('/message-overlay.html'));
check('message round-trips exactly', u.searchParams.get('message') === msg);
check('title round-trips', u.searchParams.get('title') === 'Notice!');
check('color is sanitised in the uri', u.searchParams.get('color') === 'CC0000');
check('special chars are encoded (no raw space/&/# in query string)',
!/[ #]/.test(u.search) && (u.search.match(/&/g) || []).length === 2);
// appends with '&' when the base already has a query
const uri2 = buildOverlayUri(base + '?v=2', { message: 'hi', color: 'abcdef' });
check("appends with '&' when base has a query", uri2.includes('?v=2&') && new URL(uri2).searchParams.get('message') === 'hi');
// arg parsing
const a = parseArgs(['Hello world', '--title', 'NOTICE', '--duration', '60', '--clear']);
check('parseArgs captures positional message', a._[0] === 'Hello world');
check('parseArgs reads flag values', a.title === 'NOTICE' && a.duration === '60');
check('parseArgs sets boolean --clear', a.clear === true);
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,92 @@
# CAP-AU → ScreenTinker PiP alert monitor (example)
Watches a CAP-AU emergency feed (default: the **NSW RFS `majorIncidentsCAP`** feed) and,
when a qualifying alert covers a screen's location, pushes a **PiP web overlay** to that
screen — then clears it when the alert expires, is cancelled, or leaves the feed.
It uses the **existing** ScreenTinker PiP API (`POST /api/pip`, `POST /api/pip/clear`).
No server changes required.
## How it works
```
CAP-AU feed ──poll──▶ parse (EDXL unwrap) ──▶ gate (AlertLevel + geofence) ──▶ POST /api/pip
◀─ clear on expiry/cancel/gone
```
Three non-obvious things this example gets right, learned from the real feed:
1. **It's EDXL-DE wrapped.** The feed is not a flat list of CAP alerts — each `<alert>`
is embedded under `EDXLDistribution > contentObject > xmlContent > embeddedXMLContent`.
`cap-parse.js` unwraps that.
2. **Gate on `AlertLevel`, not CAP `<severity>`.** RFS leaves `<severity>`/`<urgency>`
as `Unknown` for routine incidents. The real urgency lives in a `<parameter>` named
`AlertLevel` (`Planned Burn` / `Advice` / `Watch and Act` / `Emergency Warning`).
Default threshold shows only `Watch and Act` and `Emergency Warning`, so routine
hazard-reduction burns never hit a screen.
3. **CAP coordinates are `lat,lon`** — the reverse of GeoJSON's `lon,lat`. The geofence
keeps that flip in one place; feeding raw CAP coords into a `lon,lat` library is the
classic "matches on the wrong side of the planet" bug.
## Setup
```bash
npm install
cp config.example.json config.json # then edit it
```
In `config.json`:
- `api_base` — your ScreenTinker server URL.
- `api_token` — an **`st_` API token with the `full` scope** (PiP is fleet-affecting and
full-trust, so the route requires it). Create one in the dashboard's API-token section.
- `overlay_base_url` — where `alert-overlay.html` is hosted, **reachable by the player**
(the player fetches the overlay URL directly). Drop the file on the ScreenTinker host
or any static host.
- `screens` — each screen's `lat`/`lon` (its physical location, used for the geofence)
and the `device_id` (a device **or** group id) to push the overlay to.
- `alert_levels` — the AlertLevel threshold (default `["Watch and Act","Emergency Warning"]`).
## Run
```bash
npm start # uses ./config.json
# or
node monitor.js /path/to/config.json
```
On `Ctrl-C` it clears any overlays it put up, so a screen never keeps a stale alert.
## Test the parser (no server needed)
```bash
npm test
```
Runs the EDXL/gate/geofence logic against `fixture-feed.xml` (two real RFS planned burns
plus a synthetic Emergency Warning and a distant Watch-and-Act) and asserts that only the
in-area Emergency Warning would fire.
## Files
| File | Purpose |
|---|---|
| `monitor.js` | Poll loop + PiP show/clear lifecycle (dedup by CAP identifier). |
| `cap-parse.js` | EDXL unwrap, AlertLevel/field extraction, polygon+circle geofence, gate. |
| `alert-overlay.html` | The web overlay the PiP points at; renders from `?level=&headline=&area=…`. |
| `config.example.json` | Copy to `config.json` and fill in. |
| `fixture-feed.xml` / `test-parse.js` | Offline test of the parser/gate. |
## Notes / next steps
- **Targeting model:** one screen → one `device_id` here. For many screens you'd likely
drive `screens` from your device inventory (each device's stored location) rather than
hand-listing them.
- **`msgType` Update:** currently an Update re-shows only if the identifier changed; if RFS
reuses an identifier on update you may want to force a re-push (clear + show) to refresh
the overlay content.
- **Other states/agencies:** point `feed_url` at other CAP-AU sources (state SES/fire
services). Field names in `<parameter>` are RFS-specific; other agencies differ, so the
`AlertLevel` mapping may need adjusting per source.
- This is an example/reference, not a life-safety system. Don't make it the only way people
are warned.

View file

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emergency Alert</title>
<style>
/* Rendered inside the PiP box; transparent behind the card. Inline <style> is allowed
by the server CSP (styleSrc 'self' 'unsafe-inline'); the SCRIPT is external because
scriptSrc is 'self' with no 'unsafe-inline' — inline <script> would be blocked. */
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(18px, 4vw, 30px); }
.band .pulse { width: 16px; height: 16px; border-radius: 50%; background: rgba(255,255,255,.95);
animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
.body { padding: 18px 24px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.headline { font-size: clamp(20px, 5vw, 38px); font-weight: 700; line-height: 1.15; }
.meta { font-size: clamp(13px, 2.6vw, 18px); color: #cfcfcf; display: flex; flex-wrap: wrap; gap: 6px 18px; }
.meta b { color: #fff; font-weight: 600; }
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
.agency { opacity: .8; }
</style>
</head>
<body>
<div class="card">
<div class="band" id="band"><span class="pulse"></span><span id="level">ALERT</span></div>
<div class="body">
<div class="headline" id="headline"></div>
<div class="meta" id="meta"></div>
<div class="footer"><span class="agency">NSW Rural Fire Service</span> <span id="updated"></span></div>
</div>
</div>
<!-- external, same-origin: satisfies scriptSrc 'self' -->
<script src="overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,183 @@
'use strict';
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
// wrap their alerts the same way). Three jobs:
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
// flip in one place so callers never have to think about it.
const { XMLParser } = require('fast-xml-parser');
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
parseTagValue: false, // keep everything as strings; we coerce deliberately
trimValues: true,
});
// Always work with arrays even when the XML has a single child.
function arr(x) {
if (x === undefined || x === null) return [];
return Array.isArray(x) ? x : [x];
}
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
// these instead of regexing the HTML-encoded <description> blob.
function paramsToMap(info) {
const out = {};
for (const p of arr(info && info.parameter)) {
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
}
return out;
}
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
function parsePolygon(str) {
if (!str) return null;
const pts = String(str).trim().split(/\s+/).map((pair) => {
const [lat, lon] = pair.split(',').map(Number);
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
}).filter(Boolean);
return pts.length >= 3 ? pts : null;
}
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
// which can never contain anything, so callers should treat a 0-radius circle as "no
// usable circle" and rely on the polygon.
function parseCircle(str) {
if (!str) return null;
const [center, radius] = String(str).trim().split(/\s+/);
const [lat, lon] = (center || '').split(',').map(Number);
const km = Number(radius);
if (![lat, lon, km].every(Number.isFinite)) return null;
return { lat, lon, km };
}
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
function pointInPolygon(pt, poly) {
const x = pt.lon, y = pt.lat;
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].lon, yi = poly[i].lat;
const xj = poly[j].lon, yj = poly[j].lat;
const intersect = (yi > y) !== (yj > y) &&
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
function haversineKm(a, b) {
const R = 6371;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
// circle. Returns false when the alert has no usable geometry.
function pointInAlertArea(point, alert) {
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
return false;
}
// Flatten one embedded CAP <alert> into the shape the monitor works with.
function normaliseAlert(a) {
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
const params = paramsToMap(info);
return {
identifier: a.identifier != null ? String(a.identifier) : null,
msgType: a.msgType || null, // Alert | Update | Cancel
sent: a.sent || null,
headline: info.headline || params.IncidentName || '(no headline)',
event: info.event || null,
category: info.category || null,
responseType: info.responseType || null, // mostly "Monitor" in this feed
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
expires: info.expires || null,
web: info.web || null,
// RFS-specific, the field that actually carries urgency:
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
incidentType: params.IncidentType || null,
status: params.Status || null,
size: params.Fireground || params.Size || null,
council: params.CouncilArea || params.Location || null,
isFire: (params.IsFire || '').toLowerCase() === 'yes',
polygon: parsePolygon(area.polygon),
circle: parseCircle(area.circle),
areaDesc: area.areaDesc || null,
params,
};
}
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
function parseFeed(xml) {
const root = parser.parse(xml);
const dist = root.EDXLDistribution || root.Distribution || null;
const alerts = [];
if (dist) {
for (const co of arr(dist.contentObject)) {
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
for (const e of arr(embedded)) {
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
}
}
} else {
// Fallback: a bare CAP feed (no EDXL envelope).
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
}
return alerts;
}
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
function isExpired(alert, now = Date.now()) {
if (!alert.expires) return false;
const t = Date.parse(alert.expires);
return Number.isFinite(t) && t <= now;
}
// The gate: should this alert put something on a screen at `point`?
// - msgType must be Alert/Update (Cancel clears, never shows)
// - not expired
// - AlertLevel is at or above the configured threshold
// - the screen falls inside the alert area
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
function shouldShow(alert, point, opts = {}) {
const levels = opts.alertLevels || DEFAULT_LEVELS;
const now = opts.now || Date.now();
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
}
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
return { show: false, reason: 'no usable geometry' };
}
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
return { show: true, reason: 'in-area, at/above threshold' };
}
module.exports = {
parseFeed,
normaliseAlert,
parsePolygon,
parseCircle,
pointInPolygon,
pointInAlertArea,
haversineKm,
isExpired,
shouldShow,
DEFAULT_LEVELS,
};

View file

@ -0,0 +1,29 @@
{
"feed_url": "https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml",
"poll_interval_sec": 120,
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/alerts/alert-overlay.html",
"alert_levels": ["Watch and Act", "Emergency Warning"],
"screens": [
{ "name": "Foyer TV", "lat": -33.8688, "lon": 151.2093, "device_id": "DEVICE_OR_GROUP_ID_1" },
{ "name": "Cafe board", "lat": -33.7969, "lon": 151.2870, "device_id": "DEVICE_OR_GROUP_ID_2" }
],
"overlay": {
"position": "center",
"width": 900,
"height": 320,
"opacity": 1,
"border_radius": 16,
"colors": {
"Emergency Warning": "CC0000",
"Watch and Act": "E8730C",
"Advice": "F2C200"
}
}
}

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?><?xml-stylesheet href="lib/RFS_EDXL_simple.xsl" type="text/xsl"?>
<EDXLDistribution xmlns="urn:oasis:names:tc:emergency:EDXL:DE:1.0">
<distributionID>RFSUniqueID:2026-06-18T00:00:00Z</distributionID>
<senderID>webmaster@rfs.nsw.gov.au</senderID>
<dateTimeSent>2026-06-18T10:00:00+10:00</dateTimeSent>
<distributionStatus>Actual</distributionStatus>
<distributionType>Report</distributionType>
<contentObject>
<contentDescription>Information on Aberdeen HR</contentDescription>
<xmlContent><embeddedXMLContent>
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>2026-06-17T14:46:00.0000000:662900</identifier>
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-17T14:46:00+10:00</sent>
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
<info>
<category>Fire</category><event>Fire</event><responseType>Monitor</responseType>
<urgency>Unknown</urgency><severity>Unknown</severity><certainty>Observed</certainty>
<expires>2026-06-30T21:25:21+10:00</expires>
<headline>Aberdeen HR</headline>
<parameter><valueName>AlertLevel</valueName><value>Planned Burn</value></parameter>
<parameter><valueName>IncidentType</valueName><value>Hazard Reduction</value></parameter>
<parameter><valueName>Status</valueName><value>Under control</value></parameter>
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
<area>
<areaDesc>STANBOROUGH</areaDesc>
<polygon>-29.974,151.103 -29.984,151.103 -29.984,151.108 -29.974,151.108 -29.974,151.103</polygon>
<circle>-29.978,151.105 0</circle>
</area>
</info>
</alert>
</embeddedXMLContent></xmlContent>
</contentObject>
<contentObject>
<contentDescription>Emergency Warning - Test Ridge</contentDescription>
<xmlContent><embeddedXMLContent>
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>2026-06-18T09:30:00.0000000:670001</identifier>
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:30:00+10:00</sent>
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
<info>
<category>Fire</category><event>Bushfire</event><responseType>Evacuate</responseType>
<urgency>Immediate</urgency><severity>Extreme</severity><certainty>Observed</certainty>
<expires>2026-06-30T21:00:00+10:00</expires>
<headline>Test Ridge Road Fire</headline>
<parameter><valueName>AlertLevel</valueName><value>Emergency Warning</value></parameter>
<parameter><valueName>IncidentType</valueName><value>Bush Fire</value></parameter>
<parameter><valueName>Status</valueName><value>Out of control</value></parameter>
<parameter><valueName>CouncilArea</valueName><value>Testshire</value></parameter>
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
<area>
<areaDesc>Test Ridge - 5km around the screen</areaDesc>
<polygon>-33.90,151.10 -33.90,151.30 -33.80,151.30 -33.80,151.10 -33.90,151.10</polygon>
<circle>-33.85,151.20 8</circle>
</area>
</info>
</alert>
</embeddedXMLContent></xmlContent>
</contentObject>
<contentObject>
<contentDescription>Watch and Act - far away</contentDescription>
<xmlContent><embeddedXMLContent>
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>2026-06-18T09:45:00.0000000:670002</identifier>
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:45:00+10:00</sent>
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
<info>
<category>Fire</category><event>Bushfire</event><responseType>Prepare</responseType>
<urgency>Expected</urgency><severity>Severe</severity><certainty>Likely</certainty>
<expires>2026-06-30T21:00:00+10:00</expires>
<headline>Distant Valley Fire</headline>
<parameter><valueName>AlertLevel</valueName><value>Watch and Act</value></parameter>
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
<area>
<areaDesc>Distant Valley (far from screen)</areaDesc>
<polygon>-31.00,150.00 -31.00,150.10 -30.90,150.10 -30.90,150.00 -31.00,150.00</polygon>
<circle>-30.95,150.05 0</circle>
</area>
</info>
</alert>
</embeddedXMLContent></xmlContent>
</contentObject>
</EDXLDistribution>

View file

@ -0,0 +1,175 @@
'use strict';
// CAP-AU -> ScreenTinker PiP monitor.
//
// Polls a CAP-AU feed (default: the NSW RFS majorIncidentsCAP feed), and for each
// configured screen, pushes a PiP web overlay when a qualifying alert covers that
// screen's location — then clears it when the alert expires, is cancelled, or drops
// out of the feed. It talks to the EXISTING ScreenTinker PiP API (POST /api/pip and
// POST /api/pip/clear); it adds no server code.
//
// node monitor.js [path/to/config.json]
//
// Requires Node 18+ (uses global fetch). The config needs an st_ API token with the
// 'full' scope (PiP is fleet-affecting and full-trust, so the route demands it).
const fs = require('fs');
const path = require('path');
const cap = require('./cap-parse');
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try {
cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
console.error(`Could not read config at ${configPath}: ${e.message}`);
console.error('Copy config.example.json to config.json and fill it in.');
process.exit(1);
}
const FEED_URL = cfg.feed_url || 'https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml';
const POLL_SEC = cfg.poll_interval_sec || 120; // RFS refreshes ~every 30 min; 2 min poll is plenty
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url; // where alert-overlay.html is hosted, reachable BY THE PLAYER
const SCREENS = cfg.screens || []; // [{ name, lat, lon, device_id }]
const ALERT_LEVELS = cfg.alert_levels || cap.DEFAULT_LEVELS;
const OVERLAY = cfg.overlay || {};
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || SCREENS.length === 0) {
console.error('config must set api_base, api_token, overlay_base_url, and at least one screen.');
process.exit(1);
}
// active overlays: key `${device_id}|${identifier}` -> { pip_id, expiresAt }
const active = new Map();
const keyFor = (deviceId, identifier) => `${deviceId}|${identifier}`;
// Colour the overlay by alert level (overridable in config.overlay.colors).
const LEVEL_COLORS = Object.assign(
{ 'Emergency Warning': 'CC0000', 'Watch and Act': 'E8730C', 'Advice': 'F2C200' },
OVERLAY.colors || {},
);
function overlayUri(alert) {
const color = LEVEL_COLORS[alert.alertLevel] || 'CC0000';
const q = new URLSearchParams({
level: alert.alertLevel || '',
headline: alert.headline || '',
area: alert.areaDesc || alert.council || '',
status: alert.status || '',
updated: alert.sent || '',
color: color,
more: alert.web || '',
});
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
}
async function pipShow(deviceId, alert) {
const body = {
device_id: deviceId,
type: 'web',
uri: overlayUri(alert),
position: OVERLAY.position || 'center',
width: OVERLAY.width || 900,
height: OVERLAY.height || 320,
duration: 0, // 0 = until we explicitly clear it
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
close_button: false,
title: alert.alertLevel || 'Alert',
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear(deviceId, pipId) {
const res = await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: deviceId, pip_id: pipId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
}
}
async function tick() {
let alerts;
try {
const res = await fetch(FEED_URL, { headers: { Accept: 'application/xml, text/xml' } });
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
alerts = cap.parseFeed(await res.text());
} catch (e) {
console.error(`[${new Date().toISOString()}] feed fetch/parse error: ${e.message}`);
return; // keep the last state; try again next tick
}
const now = Date.now();
const stillQualifying = new Set(); // keys that should remain shown this tick
for (const screen of SCREENS) {
const point = { lat: screen.lat, lon: screen.lon };
for (const alert of alerts) {
if (!alert.identifier) continue;
const decision = cap.shouldShow(alert, point, { alertLevels: ALERT_LEVELS, now });
const key = keyFor(screen.device_id, alert.identifier);
if (!decision.show) continue;
stillQualifying.add(key);
if (active.has(key)) continue; // already on screen
try {
const pipId = await pipShow(screen.device_id, alert);
active.set(key, { pip_id: pipId, expiresAt: Date.parse(alert.expires) || null });
console.log(`[${new Date().toISOString()}] SHOW "${alert.headline}" (${alert.alertLevel}) on ${screen.name} [${screen.device_id}] pip=${pipId}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] show error on ${screen.name}: ${e.message}`);
}
}
}
// Clear anything active that no longer qualifies (gone from feed, cancelled, expired,
// dropped below threshold, or moved out of area).
for (const [key, rec] of [...active.entries()]) {
if (stillQualifying.has(key)) continue;
const [deviceId] = key.split('|');
try {
await pipClear(deviceId, rec.pip_id);
active.delete(key);
console.log(`[${new Date().toISOString()}] CLEAR pip=${rec.pip_id} on ${deviceId} (no longer qualifying)`);
} catch (e) {
console.error(`[${new Date().toISOString()}] clear error: ${e.message}`);
}
}
}
async function main() {
console.log(`CAP-AU PiP monitor starting`);
console.log(` feed: ${FEED_URL}`);
console.log(` poll: every ${POLL_SEC}s`);
console.log(` levels: ${ALERT_LEVELS.join(', ')}`);
console.log(` screens: ${SCREENS.map(s => `${s.name}(${s.lat},${s.lon})`).join(', ')}`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
// On shutdown, clear everything we put up so screens don't keep a stale alert.
async function shutdown() {
clearInterval(timer);
console.log('\nclearing active overlays before exit...');
for (const [key, rec] of active.entries()) {
const [deviceId] = key.split('|');
try { await pipClear(deviceId, rec.pip_id); } catch { /* best effort */ }
}
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();

View file

@ -0,0 +1,28 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the alert fields from the URL query string and populates the card.
(function () {
var q = new URLSearchParams(location.search);
var get = function (k) { return (q.get(k) || '').trim(); };
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
document.getElementById('band').style.background = color;
document.getElementById('level').textContent = (get('level') || 'Alert').toUpperCase();
document.getElementById('headline').textContent = get('headline') || 'Emergency alert in your area';
var meta = [];
if (get('area')) meta.push('<b>Area:</b> ' + escapeHtml(get('area')));
if (get('status')) meta.push('<b>Status:</b> ' + escapeHtml(get('status')));
document.getElementById('meta').innerHTML = meta.join('');
var updated = get('updated');
if (updated) {
var d = new Date(updated);
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleString('en-AU'));
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
})();

View file

@ -0,0 +1,15 @@
{
"name": "cap-alert-monitor",
"version": "0.1.0",
"description": "Example: monitor a CAP-AU feed and push emergency alerts to ScreenTinker screens via the PiP API.",
"type": "commonjs",
"main": "monitor.js",
"scripts": {
"start": "node monitor.js",
"test": "node test-parse.js"
},
"engines": { "node": ">=18" },
"dependencies": {
"fast-xml-parser": "^4.5.0"
}
}

View file

@ -0,0 +1,43 @@
const fs = require('fs');
const cap = require('./cap-parse');
const xml = fs.readFileSync('./fixture-feed.xml', 'utf8');
const alerts = cap.parseFeed(xml);
// A screen physically located inside the Emergency Warning area.
const SCREEN = { lat: -33.85, lon: 151.20 };
const now = Date.parse('2026-06-18T10:00:00+10:00');
console.log(`Parsed ${alerts.length} alert(s) from the EDXL envelope:\n`);
for (const a of alerts) {
const g = cap.shouldShow(a, SCREEN, { now });
console.log(`${a.headline}`);
console.log(` alertLevel=${a.alertLevel} severity(CAP)=${a.severity} msgType=${a.msgType}`);
console.log(` geometry: polygon=${a.polygon ? a.polygon.length + 'pts' : 'none'} circle=${a.circle ? a.circle.km + 'km' : 'none'}`);
console.log(` => ${g.show ? 'SHOW PiP' : 'skip'} (${g.reason})\n`);
}
// Assertions
const byLevel = Object.fromEntries(alerts.map(a => [a.alertLevel, a]));
const results = alerts.map(a => ({ h: a.headline, show: cap.shouldShow(a, SCREEN, { now }).show }));
const shown = results.filter(r => r.show).map(r => r.h);
const expectShown = ['Test Ridge Road Fire'];
const ok =
shown.length === 1 &&
shown[0] === 'Test Ridge Road Fire' &&
cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason.includes('below threshold') &&
cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason === 'outside area';
console.log('--- assertions ---');
console.log('only the in-area Emergency Warning shows:', shown.join(', ') || '(none)');
console.log('planned burn filtered by threshold:', cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason);
console.log('distant watch-and-act filtered by geofence:', cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason);
// lat/lon flip sanity: the screen point must NOT be found if we naively swap to lon,lat
const swapped = { lat: SCREEN.lon, lon: SCREEN.lat };
const ew = byLevel['Emergency Warning'];
console.log('flip guard (swapped coords should be OUTSIDE):', cap.pointInAlertArea(swapped, ew) ? 'FAIL (matched)' : 'ok (no match)');
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

3
Examples/PIP-Crypto-Ticker/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,112 @@
# PiP Crypto Ticker
A live cryptocurrency **price ticker** for ScreenTinker screens. Polls
[CoinGecko](https://www.coingecko.com/en/api)'s keyless `simple/price` endpoint and
pushes a wide ticker-strip overlay via the **PiP API**. Each poll refreshes the same
overlay in place; prices update without a flash.
No API key required. Zero runtime dependencies (Node 18+ global `fetch`).
```
┌────────────────────────────────────────────────────────────────┐
│ BTC $64,012.34 ▲ +1.23% • ETH $3,380.10 ▼ -0.46% • SOL … │
└────────────────────────────────────────────────────────────────┘
```
## How it works
1. `ticker.js` fetches `GET /api/v3/simple/price?ids=…&vs_currencies=…&include_24hr_change=true`.
2. It normalises the response into ordered items and encodes them compactly into the
overlay URL's query string (`items=BTC:64012.34:+1.23,…`).
3. It pushes a `type: "web"` PiP overlay (`duration: 0`, i.e. persistent) pointing at
`ticker-overlay.html`, which renders the strip. Up = green ▲, down = red ▼, flat = grey.
4. On the next poll it pushes again — the player keeps a single overlay slot
(last-show-wins), so the numbers refresh in place.
5. `Ctrl-C` (SIGINT) clears the overlay.
## Files
| file | purpose |
|------|---------|
| `ticker.js` | poller + PiP pusher (and the pure, exported normaliser/encoder) |
| `ticker-overlay.html` / `ticker-overlay.js` | the overlay page (served by the signage server) |
| `config.example.json` | copy to `config.json` and fill in |
| `fixture-prices.json` | a saved CoinGecko response for the offline test |
| `test.js` | offline test — no network, no PiP push |
## Setup
The overlay page must be served **same-origin** with the signage server (the player
loads it in an iframe, and the server CSP only allows same-origin scripts). Copy the
two overlay files into the server's static frontend directory:
```sh
cp ticker-overlay.html ticker-overlay.js /path/to/screentinker/frontend/
```
Then they're reachable at `https://<your-server>/ticker-overlay.html`.
Create a **full-scope** `st_` API token in the dashboard (Settings → API tokens), then:
```sh
cp config.example.json config.json
# edit config.json: api_base, api_token, overlay_base_url, device_id, coins
node ticker.js
```
`device_id` may be a single device **or** a device group id.
### Config
| key | meaning |
|-----|---------|
| `api_base` | signage server base URL |
| `api_token` | full-scope `st_` token |
| `overlay_base_url` | URL of the served `ticker-overlay.html` |
| `device_id` | target device or group id |
| `vs_currency` | `usd`, `eur`, `gbp`, … |
| `coins` | array of `{ id, symbol }``id` is the CoinGecko id |
| `poll_interval_sec` | refresh cadence (default 120; respect CoinGecko rate limits) |
| `position` | `bottom-right` (default), `top-left`, … |
| `width` / `height` | overlay box px (default 1100×110) |
## Local quick-start (this machine)
A local ScreenTinker instance is already running on `https://localhost:3443` with a
paired web player (device `DEVICE_OR_GROUP_ID`). It uses a self-signed
cert, so set `NODE_TLS_REJECT_UNAUTHORIZED=0`.
```sh
# 1. serve the overlay assets from the local frontend dir
cp ticker-overlay.html ticker-overlay.js /home/owner/Downloads/remote_display/frontend/
# 2. config.json
cat > config.json <<'JSON'
{
"api_base": "https://localhost:3443/",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://localhost:3443/ticker-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"vs_currency": "usd",
"coins": [
{ "id": "bitcoin", "symbol": "BTC" },
{ "id": "ethereum", "symbol": "ETH" },
{ "id": "solana", "symbol": "SOL" }
],
"poll_interval_sec": 120,
"position": "bottom-right"
}
JSON
# 3. run
NODE_TLS_REJECT_UNAUTHORIZED=0 node ticker.js
```
## Test (offline)
```sh
npm test
```
Validates price/percent formatting, up/down/flat direction, and that the compact
`items` encoding round-trips through the overlay's decoder. Prints `RESULT: PASS ✅`.

View file

@ -0,0 +1,21 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/ticker-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"vs_currency": "usd",
"coins": [
{ "id": "bitcoin", "symbol": "BTC" },
{ "id": "ethereum", "symbol": "ETH" },
{ "id": "solana", "symbol": "SOL" },
{ "id": "cardano", "symbol": "ADA" }
],
"poll_interval_sec": 120,
"position": "bottom-right",
"width": 1100,
"height": 110,
"border_radius": 14,
"opacity": 1
}

View file

@ -0,0 +1,6 @@
{
"bitcoin": { "usd": 64012.34, "usd_24h_change": 1.2345 },
"ethereum": { "usd": 3380.1, "usd_24h_change": -0.4567 },
"solana": { "usd": 152.4, "usd_24h_change": 0.002 },
"cardano": { "usd": 0.3821, "usd_24h_change": -2.8 }
}

View file

@ -0,0 +1,12 @@
{
"name": "pip-crypto-ticker",
"version": "0.1.0",
"description": "Example: poll CoinGecko (keyless) and push a live crypto price ticker to ScreenTinker screens via the PiP API.",
"type": "commonjs",
"main": "ticker.js",
"scripts": {
"start": "node ticker.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,62 @@
'use strict';
// Offline test: no network, no PiP push. Proves the normaliser formats prices and
// changes, derives direction from the 24h change, and that the compact items
// encoding round-trips through the overlay's decoder.
const fs = require('fs');
const t = require('./ticker');
const raw = JSON.parse(fs.readFileSync('./fixture-prices.json', 'utf8'));
const coins = [
{ id: 'bitcoin', symbol: 'BTC' },
{ id: 'ethereum', symbol: 'ETH' },
{ id: 'solana', symbol: 'SOL' },
{ id: 'cardano', symbol: 'ADA' },
];
const items = t.normalise(raw, { coins, vs_currency: 'usd' });
console.log('Normalised ticker items:\n');
for (const i of items) {
console.log(`${i.symbol} ${i.priceStr} ${i.changeStr} (${i.dir})`);
}
const encoded = t.encodeItems(items);
const decoded = t.decodeItems(encoded);
console.log(`\nencoded: ${encoded}\n`);
function eq(a, b, msg) { if (a !== b) { console.error(`${msg}: got ${JSON.stringify(a)} want ${JSON.stringify(b)}`); return false; } return true; }
let ok = true;
// order + count preserved
ok = eq(items.length, 4, 'item count') && ok;
ok = eq(items.map(i => i.symbol).join(','), 'BTC,ETH,SOL,ADA', 'symbol order') && ok;
// formatting: thousands separators, decimal precision by magnitude
ok = eq(items[0].priceStr, '64,012.34', 'BTC thousands+2dp') && ok;
ok = eq(items[0].changeStr, '+1.23%', 'BTC change sign') && ok;
ok = eq(items[0].dir, 'up', 'BTC dir') && ok;
ok = eq(items[1].priceStr, '3,380.10', 'ETH trailing zero') && ok;
ok = eq(items[1].dir, 'down', 'ETH dir (negative)') && ok;
// near-zero change rounds to flat
ok = eq(items[2].changeStr, '+0.00%', 'SOL ~0 change') && ok;
ok = eq(items[2].dir, 'flat', 'SOL dir flat') && ok;
// sub-$1 coin gets extra decimals, no thousands grouping
ok = eq(items[3].priceStr, '0.3821', 'ADA 4dp sub-dollar') && ok;
ok = eq(items[3].dir, 'down', 'ADA dir') && ok;
// round-trip: decoded display fields match the normaliser's
ok = eq(decoded.length, items.length, 'decoded count') && ok;
for (let k = 0; k < items.length; k++) {
ok = eq(decoded[k].symbol, items[k].symbol, `rt[${k}] symbol`) && ok;
ok = eq(decoded[k].priceStr, items[k].priceStr, `rt[${k}] priceStr`) && ok;
ok = eq(decoded[k].changeStr, items[k].changeStr, `rt[${k}] changeStr`) && ok;
ok = eq(decoded[k].dir, items[k].dir, `rt[${k}] dir`) && ok;
}
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Crypto Ticker</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; align-items: stretch; }
.strip { flex: 1; display: flex; align-items: center; gap: 10px;
background: #14161a; color: #fff; border-radius: 14px; overflow: hidden;
padding: 0 16px; box-shadow: 0 8px 28px rgba(0,0,0,.45); }
.row { display: flex; align-items: center; gap: clamp(14px, 3vw, 34px);
overflow: hidden; white-space: nowrap; width: 100%; }
.chip { display: inline-flex; align-items: baseline; gap: 8px; }
.sym { font-weight: 800; letter-spacing: .04em; font-size: clamp(15px, 3.2vw, 26px); }
.price { font-weight: 600; font-variant-numeric: tabular-nums; font-size: clamp(15px, 3.2vw, 26px); }
.chg { font-weight: 700; font-variant-numeric: tabular-nums; font-size: clamp(13px, 2.6vw, 20px); }
.up { color: #2ecc71; }
.down { color: #ff5b5b; }
.flat { color: #9aa0a6; }
.dot { color: #3a3f47; }
.empty { color: #9aa0a6; font-size: clamp(14px, 3vw, 22px); }
</style>
</head>
<body>
<div class="strip"><div class="row" id="row"></div></div>
<script src="ticker-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,62 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Parses the compact `items` query (SYMBOL:rawprice:signedchange, comma-joined) and
// renders a horizontal ticker strip. Mirrors decodeItems() in ticker.js.
(function () {
var q = new URLSearchParams(location.search);
var items = (q.get('items') || '').trim();
var cur = (q.get('cur') || 'usd').toLowerCase();
var CUR = { usd: '$', eur: '€', gbp: '£', jpy: '¥', aud: 'A$', cad: 'C$' };
var sym = CUR[cur] || '';
function addThousands(numStr) {
var neg = numStr.charAt(0) === '-';
var s = neg ? numStr.slice(1) : numStr;
var parts = s.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (neg ? '-' : '') + parts.join('.');
}
function dirOf(chg) {
var r = Number(parseFloat(chg).toFixed(2));
return r > 0 ? 'up' : (r < 0 ? 'down' : 'flat');
}
function arrow(dir) { return dir === 'up' ? '▲' : dir === 'down' ? '▼' : '■'; }
var row = document.getElementById('row');
var toks = items ? items.split(',').filter(Boolean) : [];
if (toks.length === 0) {
var e = document.createElement('span');
e.className = 'empty';
e.textContent = 'No market data';
row.appendChild(e);
return;
}
toks.forEach(function (tok, idx) {
var p = tok.split(':');
var symbol = p[0] || '';
var priceRaw = p[1] || '0';
var chg = p[2] || '+0.00';
var dir = dirOf(chg);
var chip = document.createElement('span');
chip.className = 'chip';
var s = document.createElement('span');
s.className = 'sym'; s.textContent = symbol;
var pr = document.createElement('span');
pr.className = 'price'; pr.textContent = sym + addThousands(priceRaw);
var c = document.createElement('span');
c.className = 'chg ' + dir; c.textContent = arrow(dir) + ' ' + chg + '%';
chip.appendChild(s); chip.appendChild(pr); chip.appendChild(c);
row.appendChild(chip);
if (idx < toks.length - 1) {
var dot = document.createElement('span');
dot.className = 'dot'; dot.textContent = '•';
row.appendChild(dot);
}
});
})();

View file

@ -0,0 +1,209 @@
'use strict';
// Crypto price ticker -> ScreenTinker PiP overlay.
//
// Polls CoinGecko's keyless simple/price endpoint and pushes a wide "ticker strip"
// web overlay to a device or group. Each poll refreshes the same overlay slot
// (last-show-wins on the player), so prices update in place. The overlay is
// persistent (duration 0) and is cleared on SIGINT/SIGTERM.
//
// node ticker.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
// ---- pure helpers (exported for offline tests) --------------------------------
const CUR_SYMBOL = { usd: '$', eur: '€', gbp: '£', jpy: '¥', aud: 'A$', cad: 'C$' };
// Decimals scale with magnitude: cheap coins need more precision than BTC.
function priceDecimals(p) {
const a = Math.abs(Number(p) || 0);
if (a >= 1) return 2;
if (a >= 0.01) return 4;
return 6;
}
// Group the integer part with thousands separators; keep the fractional part as-is.
function addThousands(numStr) {
const neg = numStr.startsWith('-');
const s = neg ? numStr.slice(1) : numStr;
const [int, frac] = s.split('.');
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (neg ? '-' : '') + grouped + (frac != null ? '.' + frac : '');
}
// Raw (delimiter-safe) numeric price string: fixed decimals, NO thousands commas.
function priceRaw(p) { return (Number(p) || 0).toFixed(priceDecimals(p)); }
// Display price string: thousands-separated.
function formatPrice(p) { return addThousands(priceRaw(p)); }
// Signed change, 2 decimals, no % (compact for the query). e.g. "+1.23", "-0.45".
function signedChange(c) {
const n = Number(c) || 0;
return (n >= 0 ? '+' : '') + n.toFixed(2);
}
// Display change with % suffix. e.g. "+1.23%".
function formatChange(c) { return signedChange(c) + '%'; }
// Direction from the rounded 2-decimal change, so it matches what's displayed.
function dirOf(c) {
const r = Number((Number(c) || 0).toFixed(2));
if (r > 0) return 'up';
if (r < 0) return 'down';
return 'flat';
}
// CoinGecko simple/price response -> normalised items, preserving config coin order.
// raw[coinId][vs] = price ; raw[coinId][vs+"_24h_change"] = pct change
function normalise(raw, opts = {}) {
const vs = (opts.vs_currency || 'usd').toLowerCase();
const coins = opts.coins || [];
const out = [];
for (const coin of coins) {
const entry = raw && raw[coin.id];
if (!entry || entry[vs] == null) continue;
const price = Number(entry[vs]);
const change = Number(entry[`${vs}_24h_change`]) || 0;
out.push({
symbol: coin.symbol || coin.id.toUpperCase(),
price,
priceStr: formatPrice(price),
change24h: change,
changeStr: formatChange(change),
dir: dirOf(change),
});
}
return out;
}
// Compact, comma/colon-delimited encoding for the overlay query string.
// "BTC:64012.34:+1.23,ETH:3380.10:-0.45"
function encodeItems(items) {
return items.map(i => `${i.symbol}:${priceRaw(i.price)}:${signedChange(i.change24h)}`).join(',');
}
// Inverse of encodeItems — mirrors the parser in ticker-overlay.js. Returns the
// display-ready shape so a test can prove the round-trip survives.
function decodeItems(s) {
if (!s) return [];
return s.split(',').filter(Boolean).map(tok => {
const [symbol, priceRawStr, chg] = tok.split(':');
return {
symbol,
priceStr: addThousands(priceRawStr),
changeStr: chg + '%',
dir: dirOf(parseFloat(chg)),
};
});
}
// ---- live runner --------------------------------------------------------------
function cgUrl(coins, vs) {
const ids = coins.map(c => c.id).join(',');
const q = new URLSearchParams({ ids, vs_currencies: vs, include_24hr_change: 'true' });
return `https://api.coingecko.com/api/v3/simple/price?${q.toString()}`;
}
function overlayUri(base, items, vs) {
const q = new URLSearchParams({ items: encodeItems(items), cur: vs });
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
async function main() {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const COINS = cfg.coins || [];
const VS = (cfg.vs_currency || 'usd').toLowerCase();
const POLL_SEC = cfg.poll_interval_sec || 120;
const POS = cfg.position || 'bottom-right';
const WIDTH = cfg.width || 1100;
const HEIGHT = cfg.height || 110;
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE || COINS.length === 0) {
console.error('config must set api_base, api_token, overlay_base_url, device_id, and at least one coin.');
process.exit(1);
}
let pipId = null;
async function show(items) {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, items, VS),
position: POS, width: WIDTH, height: HEIGHT, duration: 0,
opacity: cfg.opacity != null ? cfg.opacity : 1,
border_radius: cfg.border_radius != null ? cfg.border_radius : 14,
close_button: false,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
pipId = json.pip_id;
return items;
}
async function clear() {
if (!pipId) return;
try {
await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
});
} catch { /* best effort */ }
}
async function tick() {
let raw;
try {
const res = await fetch(cgUrl(COINS, VS), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`);
raw = await res.json();
} catch (e) { console.error(`[${new Date().toISOString()}] fetch error: ${e.message}`); return; }
const items = normalise(raw, { coins: COINS, vs_currency: VS });
if (items.length === 0) { console.error(`[${new Date().toISOString()}] no prices for configured coins`); return; }
try {
await show(items);
const line = items.map(i => `${i.symbol} ${CUR_SYMBOL[VS] || ''}${i.priceStr} ${i.changeStr}`).join(' | ');
console.log(`[${new Date().toISOString()}] SHOW ticker pip=${pipId} :: ${line}`);
} catch (e) { console.error(`[${new Date().toISOString()}] show error: ${e.message}`); }
}
console.log(`Crypto ticker starting — ${COINS.map(c => c.symbol).join(', ')} in ${VS.toUpperCase()}, poll every ${POLL_SEC}s`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
console.log('\nclearing ticker overlay before exit...');
await clear();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
module.exports = {
priceDecimals, addThousands, priceRaw, formatPrice,
signedChange, formatChange, dirOf, normalise, encodeItems, decodeItems,
cgUrl, overlayUri,
};
if (require.main === module) main();

View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,103 @@
# PiP Event Countdown
Push a **live, self-clearing countdown overlay** to a ScreenTinker screen (or group) with
the PiP API. The overlay ticks down `DD : HH : MM : SS` in real time and — the fun part —
**removes itself the instant the target time arrives**. There is no clearing poll: the
script sets the PiP `duration` to "seconds until the target", so the player drops the
overlay at exactly zero and shows a quick 🎉 first.
Great for: New Year's Eve, product launches, store opening / closing, shift changes,
webinar "starts in…", conference session timers, "back in 15 minutes".
## How it works
```
countdown.js --(POST /api/pip, type:web, duration = seconds-to-target)--> player
|
overlay_base_url/countdown-overlay.html?target=<ms>&title=<text> |
v
countdown-overlay.js ticks the clock every second; at zero shows 🎉 <title>
...and the player auto-removes the PiP at the same moment (duration elapsed)
```
`countdown.js` is a **one-shot** push — it doesn't stay running. Re-run it to change the
target or title; the player keeps last-show-wins, so the new overlay replaces the old.
## Files
| File | Purpose |
|------|---------|
| `countdown.js` | Computes seconds-to-target and pushes one PiP. `--clear` removes it early. |
| `countdown-overlay.html` / `countdown-overlay.js` | The overlay page the player loads in an iframe. Must be served by your ScreenTinker host (same-origin with the player). |
| `config.example.json` | Copy to `config.json` and fill in. |
| `test.js` | Offline unit test of the date math (`npm test`). |
## Setup
1. **Mint a token.** In the dashboard create an API token with the **`full`** scope
(PiP is fleet-affecting and can render arbitrary web content, so it requires `full`).
2. **Serve the overlay assets.** Copy `countdown-overlay.html` and `countdown-overlay.js`
into the directory your ScreenTinker server serves at the web root (the same place
`index.html` is served from — the `frontend/` dir in this repo). They must be reachable
at `overlay_base_url`, and **same-origin** with the player so the server's CSP
(`script-src 'self'`) allows `countdown-overlay.js`. (Inline scripts are blocked by the
CSP — that's why the JS is a separate file.)
3. **Configure.**
```bash
cp config.example.json config.json
# edit config.json: api_base, api_token, overlay_base_url, device_id, target, title
```
4. **Run.**
```bash
node countdown.js
# or override target/title on the CLI:
node countdown.js "2026-07-04T21:00:00-05:00" "Fireworks!"
# clear it early:
node countdown.js --clear
```
## config.json
| Key | Meaning |
|-----|---------|
| `api_base` | Base URL of your ScreenTinker server, e.g. `https://signage.example.com`. |
| `api_token` | A `full`-scope `st_…` token. |
| `overlay_base_url` | Public URL of `countdown-overlay.html` (served by your host). |
| `device_id` | A device **or** group id to show on. |
| `target` | Target datetime, any `Date.parse`-able string (ISO 8601 recommended, include a TZ offset). |
| `title` | Heading shown above the clock, and the 🎉 message at zero. |
| `position` | `center` (default), `top-right`, `top-left`, `bottom-right`, `bottom-left`. |
## Local quick-start (this repo's dev instance)
The dev server runs at `https://localhost:3443/` with a self-signed cert, so disable TLS
verification for the run. Copy the overlay assets into the served `frontend/` dir first so
`https://localhost:3443/countdown-overlay.html` resolves.
```bash
cp config.example.json config.json
# config.json:
# "api_base": "https://localhost:3443/"
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
# "overlay_base_url": "https://localhost:3443/countdown-overlay.html"
# "device_id": "DEVICE_OR_GROUP_ID"
# "target": a time ~2 minutes out, e.g. "2026-06-18T19:42:00-05:00"
# "title": "Demo"
NODE_TLS_REJECT_UNAUTHORIZED=0 node countdown.js
```
Watch the screen count down and disappear on its own at zero. (`config.json` is
git-ignored so your token never gets committed.)
## Notes & limits
- The PiP `duration` caps at **24h (86400s)**. For a target more than a day out the
overlay still shows, but it can't auto-clear at zero — re-run within 24h of the target
for the self-clear effect. The script warns you when the target is beyond the cap.
- PiP is **ephemeral**: it isn't part of the device's saved layout, so a player reboot
clears it. Re-run `countdown.js` after a reboot if needed.
- Offline devices are reported, not queued — show it while the screen is online.

View file

@ -0,0 +1,11 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/countdown-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"target": "2026-12-31T23:59:59-06:00",
"title": "Happy New Year",
"position": "center"
}

View file

@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Countdown</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; background: #14161c; color: #fff; border-radius: 16px; overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 20px 24px; box-sizing: border-box; }
.title { font-size: clamp(20px, 5vw, 40px); font-weight: 800; letter-spacing: .02em; text-align: center;
line-height: 1.1; }
.clock { display: flex; gap: clamp(10px, 3vw, 28px); align-items: flex-start; }
.unit { display: flex; flex-direction: column; align-items: center; min-width: clamp(48px, 12vw, 96px); }
.num { font-variant-numeric: tabular-nums; font-weight: 800; font-size: clamp(34px, 11vw, 88px);
line-height: 1; color: #fff; }
.lbl { margin-top: 8px; font-size: clamp(11px, 2.4vw, 16px); text-transform: uppercase; letter-spacing: .12em;
color: #9aa0aa; }
.sep { font-weight: 800; font-size: clamp(28px, 9vw, 70px); line-height: 1; color: #4b5160; padding-top: 2px; }
.done .num, .done .sep { color: #58d68d; }
.celebrate { font-size: clamp(30px, 8vw, 68px); font-weight: 800; text-align: center; color: #fff; }
</style>
</head>
<body>
<div class="card" id="card">
<div class="title" id="title">Countdown</div>
<div class="clock" id="clock">
<div class="unit"><span class="num" id="d">00</span><span class="lbl">Days</span></div>
<span class="sep">:</span>
<div class="unit"><span class="num" id="h">00</span><span class="lbl">Hours</span></div>
<span class="sep">:</span>
<div class="unit"><span class="num" id="m">00</span><span class="lbl">Min</span></div>
<span class="sep">:</span>
<div class="unit"><span class="num" id="s">00</span><span class="lbl">Sec</span></div>
</div>
</div>
<script src="countdown-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,53 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads ?target (epoch ms) and ?title from the URL and ticks a live DD:HH:MM:SS clock.
// When the target arrives it switches to a celebratory state. The PiP itself is removed
// by the player at the same moment (duration = seconds-to-target), so this is the visual
// that the viewer sees right before it vanishes.
(function () {
var q = new URLSearchParams(location.search);
var target = parseInt(q.get('target'), 10);
var title = (q.get('title') || 'Countdown').trim();
document.getElementById('title').textContent = title;
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
var elD = document.getElementById('d');
var elH = document.getElementById('h');
var elM = document.getElementById('m');
var elS = document.getElementById('s');
var clock = document.getElementById('clock');
var card = document.getElementById('card');
function tick() {
var secs = Math.ceil((target - Date.now()) / 1000);
if (!isFinite(target)) { return; }
if (secs <= 0) {
celebrate();
return;
}
var s = secs;
var days = Math.floor(s / 86400); s -= days * 86400;
var hours = Math.floor(s / 3600); s -= hours * 3600;
var mins = Math.floor(s / 60); s -= mins * 60;
elD.textContent = pad(days);
elH.textContent = pad(hours);
elM.textContent = pad(mins);
elS.textContent = pad(s);
}
var celebrated = false;
function celebrate() {
if (celebrated) { return; }
celebrated = true;
clearInterval(timer);
clock.classList.add('done');
elD.textContent = '00'; elH.textContent = '00'; elM.textContent = '00'; elS.textContent = '00';
var c = document.createElement('div');
c.className = 'celebrate';
c.textContent = '🎉 ' + title;
card.appendChild(c);
}
tick();
var timer = setInterval(tick, 1000);
})();

View file

@ -0,0 +1,156 @@
'use strict';
// Countdown -> ScreenTinker PiP. Pushes ONE live countdown overlay to a device or
// group and lets the player auto-clear it the instant the target time arrives, using
// the PiP `duration` field (duration = seconds-to-target, so no clear poll is needed).
//
// node countdown.js [path/to/config.json]
// node countdown.js "2026-12-31T23:59:59-06:00" "Happy New Year" # CLI override
// node countdown.js [config] --clear # clear it early
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
const PIP_DUR_MAX = 86400; // PiP API duration cap (seconds)
// --- pure, testable helpers (no I/O, explicit `now` so tests are deterministic) ---
// Whole seconds from `now` until `target` (both epoch ms), rounded UP so the last
// partial second still counts. <= 0 means the moment has already passed.
function secondsToTarget(target, now) {
return Math.ceil((target - now) / 1000);
}
// Split a non-negative second count into d/h/m/s. Negative clamps to zero.
function breakdown(seconds) {
let s = Math.max(0, Math.floor(seconds));
const days = Math.floor(s / 86400); s -= days * 86400;
const hours = Math.floor(s / 3600); s -= hours * 3600;
const minutes = Math.floor(s / 60); s -= minutes * 60;
return { days, hours, minutes, seconds: s };
}
// PiP duration to request: seconds-to-target, but never above the API cap. For targets
// more than 24h out the overlay won't auto-clear at zero (it'd hit the cap first); the
// CLI warns in that case. 0 would mean "until cleared", which we never want here.
function durationForTarget(seconds) {
return Math.max(1, Math.min(seconds, PIP_DUR_MAX));
}
module.exports = { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX };
// --- CLI ---
if (require.main === module) main();
function main() {
const args = process.argv.slice(2);
const clear = args.includes('--clear');
const positional = args.filter(a => !a.startsWith('--'));
// First positional that isn't an ISO date is treated as the config path.
let cfgPath = path.join(__dirname, 'config.json');
let cliTarget = null, cliTitle = null;
if (positional.length && Number.isFinite(Date.parse(positional[0]))) {
cliTarget = positional[0];
cliTitle = positional[1] || null;
} else if (positional.length) {
cfgPath = positional[0];
if (positional[1] && Number.isFinite(Date.parse(positional[1]))) {
cliTarget = positional[1];
cliTitle = positional[2] || null;
}
}
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
catch (e) {
if (!cliTarget) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
}
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
const apiToken = cfg.api_token;
const overlayBase = cfg.overlay_base_url;
const deviceId = cfg.device_id;
const targetIso = cliTarget || cfg.target;
const title = cliTitle || cfg.title || 'Countdown';
const position = cfg.position || 'center';
if (!apiBase || !apiToken || !deviceId) {
console.error('config must set api_base, api_token, and device_id.');
process.exit(1);
}
if (clear) { return doClear(apiBase, apiToken, deviceId); }
if (!overlayBase) { console.error('config must set overlay_base_url for a countdown overlay.'); process.exit(1); }
const targetMs = Date.parse(targetIso);
if (!Number.isFinite(targetMs)) { console.error(`invalid target datetime: ${targetIso}`); process.exit(1); }
const now = Date.now();
const secs = secondsToTarget(targetMs, now);
if (secs <= 0) {
console.log(`"${title}" target ${targetIso} has already passed — nothing to show.`);
process.exit(0);
}
if (secs > PIP_DUR_MAX) {
const b = breakdown(secs);
console.warn(`note: target is ${b.days}d ${b.hours}h away (> 24h). The overlay will show but auto-clear caps at 24h; re-run within 24h of the target for the self-clear-at-zero effect.`);
}
showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs });
}
function overlayUri(overlayBase, targetMs, title) {
const q = new URLSearchParams({ target: String(targetMs), title: title || '' });
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
}
async function showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs }) {
const duration = durationForTarget(secs);
const body = {
device_id: deviceId,
type: 'web',
uri: overlayUri(overlayBase, targetMs, title),
position,
width: 820,
height: 300,
duration,
border_radius: 16,
close_button: false,
title,
};
try {
const res = await fetch(`${apiBase}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
const b = breakdown(secs);
console.log(`SHOW "${title}" pip=${json.pip_id} target=${new Date(targetMs).toISOString()}`);
console.log(`auto-clears in ${secs}s (${b.days}d ${b.hours}h ${b.minutes}m ${b.seconds}s) — player drops it at zero, no clear call needed.`);
} catch (e) {
console.error(`pip show failed: ${e.message}`);
process.exit(1);
}
}
async function doClear(apiBase, apiToken, deviceId) {
try {
const res = await fetch(`${apiBase}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
body: JSON.stringify({ device_id: deviceId }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
} catch (e) {
console.error(`pip clear failed: ${e.message}`);
process.exit(1);
}
}

View file

@ -0,0 +1,12 @@
{
"name": "pip-event-countdown",
"version": "0.1.0",
"description": "Example: push a live, self-clearing countdown overlay to ScreenTinker screens via the PiP API.",
"type": "commonjs",
"main": "countdown.js",
"scripts": {
"start": "node countdown.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,44 @@
'use strict';
// Offline unit test for the pure countdown helpers. No network, no player.
const { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX } = require('./countdown');
let ok = true;
function check(name, cond) {
console.log(`${cond ? '•' : '✗'} ${name}`);
if (!cond) ok = false;
}
// Fixed reference instant so the test is deterministic.
const now = Date.parse('2026-06-18T12:00:00-05:00');
// 1 day, 2 hours, 3 minutes, 4 seconds in the future.
const futureSecs = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 93784
const future = now + futureSecs * 1000;
const s1 = secondsToTarget(future, now);
check(`secondsToTarget future = ${futureSecs}`, s1 === futureSecs);
const b1 = breakdown(s1);
check('breakdown days/hours/min/sec', b1.days === 1 && b1.hours === 2 && b1.minutes === 3 && b1.seconds === 4);
// Round UP: 1.4s out still counts as 2 whole seconds remaining.
check('secondsToTarget rounds up partial second', secondsToTarget(now + 1400, now) === 2);
// Past target -> non-positive.
check('past target <= 0', secondsToTarget(now - 5000, now) <= 0);
// Exactly now -> 0.
check('exactly now == 0', secondsToTarget(now, now) === 0);
// breakdown clamps negatives to zero.
const bz = breakdown(-50);
check('breakdown clamps negative to 0', bz.days === 0 && bz.hours === 0 && bz.minutes === 0 && bz.seconds === 0);
// duration clamp: under the cap is unchanged, over the cap is clamped, zero floors to 1.
check('durationForTarget passes through under cap', durationForTarget(3600) === 3600);
check('durationForTarget clamps to 24h cap', durationForTarget(PIP_DUR_MAX + 999) === PIP_DUR_MAX);
check('durationForTarget floors to >=1', durationForTarget(0) === 1);
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,96 @@
# PiP Fundraiser Thermometer
Pushes a **goal-progress "thermometer"** overlay to a ScreenTinker screen (or group) via
the PiP API. Reads a tiny JSON progress doc, computes the percentage, and shows a filling
bar with the amount raised, the goal, and the percent. It re-pushes on every poll so the
bar updates in place, and clears the overlay when you stop it.
```
progress.json ──poll──▶ thermo.js ──POST /api/pip──▶ ScreenTinker ──▶ screen
{raised,goal} (web overlay, duration 0 = persistent)
```
Great for lobby displays, telethons, membership drives, "miles walked", etc.
## Data source
A small JSON document, from a local file **or** a URL:
```json
{ "campaign": "Community Garden", "raised": 12450, "goal": 20000, "currency": "USD" }
```
- `source_file` — a path (relative to this dir or absolute). Update the file and the next
poll picks it up.
- `source_url` — any endpoint returning that JSON (e.g. a Google Sheet published as JSON,
a CRM webhook target, your own little script). If both are set, `source_url` wins.
Supported currency symbols: USD/CAD/AUD/NZD `$`, EUR `€`, GBP `£`, JPY `¥`, INR `₹`.
Anything else renders as `CODE 1,234`.
## Setup
1. **Host the overlay page.** Copy both overlay files into the ScreenTinker server's
frontend directory so they're served same-origin (the server's CSP only allows the
external `<script src>` when it's same-origin):
```
cp thermo-overlay.html thermo-overlay.js /path/to/screentinker/frontend/
```
They'll be served at `https://<your-server>/thermo-overlay.html`.
2. **Create your config:**
```
cp config.example.json config.json
```
Set `api_base`, `api_token` (an `st_` token with the **`full`** scope), `device_id`
(a device **or** group id), `overlay_base_url` (the hosted `thermo-overlay.html`), and
either `source_file` or `source_url`. Optional: `position` (default `bottom-left`),
`width`/`height`, `poll_interval_sec` (default 60), `currency`.
3. **Run it:**
```
npm start
# or: node thermo.js config.json
```
Stop with Ctrl-C — it clears the overlay on the way out.
## Local quick-start (this repo's dev server)
The local ScreenTinker dev instance serves on `https://localhost:3443` with a self-signed
cert, so prefix commands with `NODE_TLS_REJECT_UNAUTHORIZED=0`:
```bash
cp thermo-overlay.html thermo-overlay.js ../../frontend/ # serve same-origin
cp config.example.json config.json
# edit config.json:
# "api_base": "https://localhost:3443/",
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
# "overlay_base_url": "https://localhost:3443/thermo-overlay.html",
# "device_id": "DEVICE_OR_GROUP_ID",
# "source_file": "progress.example.json"
NODE_TLS_REJECT_UNAUTHORIZED=0 node thermo.js config.json
```
Edit `progress.example.json` (bump `raised`) and watch the bar climb on the next poll.
When `raised >= goal` the overlay shows **Goal reached! 🎉**.
## Test
```
npm test
```
Offline unit tests for the money formatter and the progress math
(`62.25%` → label `62%`, clamps over 100%, divide-by-zero-safe goal). Prints `RESULT: PASS`.
## Notes
- PiP overlays are **ephemeral** — a player reboot drops them; the next poll re-pushes.
- `device_id` may be a group id to fan out to every screen in the group.
- Cents are dropped on purpose (whole units read better on a wall display).

View file

@ -0,0 +1,16 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/thermo-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"source_file": "progress.example.json",
"_source_url_alt": "https://example.com/fundraiser.json",
"currency": "USD",
"poll_interval_sec": 60,
"position": "bottom-left",
"width": 460,
"height": 360
}

View file

@ -0,0 +1,12 @@
{
"name": "pip-fundraiser-thermometer",
"version": "0.1.0",
"description": "Example: push a fundraiser goal-progress thermometer overlay to ScreenTinker screens via the PiP API.",
"type": "commonjs",
"main": "thermo.js",
"scripts": {
"start": "node thermo.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,6 @@
{
"campaign": "Community Garden",
"raised": 12450,
"goal": 20000,
"currency": "USD"
}

View file

@ -0,0 +1,54 @@
'use strict';
const t = require('./thermo');
const checks = [];
const eq = (name, got, want) => checks.push({ name, ok: got === want, got, want });
// money formatting
eq('formatMoney USD', t.formatMoney(12450, 'USD'), '$12,450');
eq('formatMoney EUR', t.formatMoney(12450, 'EUR'), '€12,450');
eq('formatMoney GBP', t.formatMoney(1234567, 'GBP'), '£1,234,567');
eq('formatMoney unknown code', t.formatMoney(2500, 'BTC'), 'BTC 2,500');
eq('formatMoney small', t.formatMoney(999, 'USD'), '$999');
eq('formatMoney rounds', t.formatMoney(12450.7, 'USD'), '$12,451');
eq('groupThousands', t.groupThousands(1000000), '1,000,000');
// progress
const p1 = t.computeProgress({ raised: 12450, goal: 20000 });
eq('pct 12450/20000', p1.pct, 62.25);
eq('pctLabel 12450/20000', p1.pctLabel, '62%');
const p2 = t.computeProgress({ raised: 25000, goal: 20000 });
eq('clamp over 100 pct', p2.pct, 100);
eq('clamp over 100 label', p2.pctLabel, '100%');
const p3 = t.computeProgress({ raised: 500, goal: 0 });
eq('goal 0 -> 0 pct', p3.pct, 0);
eq('goal 0 -> 0 label', p3.pctLabel, '0%');
const p4 = t.computeProgress({ raised: 0, goal: 20000 });
eq('zero raised', p4.pct, 0);
// normalise + uri
const v = t.normalise({ campaign: 'Community Garden', raised: 12450, goal: 20000, currency: 'USD' });
eq('normalise campaign', v.campaign, 'Community Garden');
eq('normalise raisedLabel', v.raisedLabel, '$12,450');
eq('normalise goalLabel', v.goalLabel, '$20,000');
eq('normalise pctLabel', v.pctLabel, '62%');
const uri = t.overlayUri('https://s/thermo-overlay.html', v);
const parsed = new URL(uri);
eq('uri campaign round-trips', parsed.searchParams.get('campaign'), 'Community Garden');
eq('uri raised round-trips', parsed.searchParams.get('raised'), '$12,450');
eq('uri pct round-trips', parsed.searchParams.get('pct'), '62.25');
let pass = 0;
for (const c of checks) {
console.log(`${c.ok ? '✓' : '✗'} ${c.name}` + (c.ok ? '' : ` got=${JSON.stringify(c.got)} want=${JSON.stringify(c.want)}`));
if (c.ok) pass++;
}
const ok = pass === checks.length;
console.log(`\n${pass}/${checks.length} checks`);
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fundraiser Thermometer</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45);
padding: 18px 22px; box-sizing: border-box; }
.campaign { font-size: clamp(16px, 4vw, 26px); font-weight: 800; letter-spacing: .01em; line-height: 1.15; }
.stage { flex: 1; display: flex; align-items: stretch; gap: 18px; margin: 14px 0 10px; }
/* vertical thermometer */
.thermo { width: 30%; min-width: 64px; display: flex; align-items: flex-end; }
.tube { position: relative; width: 100%; height: 100%; background: #2c2c2c; border-radius: 999px;
overflow: hidden; border: 2px solid #3a3a3a; }
.fill { position: absolute; left: 0; right: 0; bottom: 0; height: 0;
background: linear-gradient(0deg, #1f9d55, #36d07f);
transition: height 1.1s cubic-bezier(.22,.9,.31,1); }
.readout { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 6px; }
.pct { font-size: clamp(34px, 12vw, 72px); font-weight: 800; line-height: 1; color: #36d07f; }
.pct.done { color: #ffd24a; }
.amounts { font-size: clamp(15px, 3.4vw, 22px); }
.amounts b { font-weight: 800; }
.amounts .of { color: #b9b9b9; font-weight: 500; }
.footer { font-size: clamp(12px, 2.4vw, 16px); color: #9a9a9a; }
.done-banner { color: #ffd24a; font-weight: 800; }
</style>
</head>
<body>
<div class="card">
<div class="campaign" id="campaign">Fundraiser</div>
<div class="stage">
<div class="thermo"><div class="tube"><div class="fill" id="fill"></div></div></div>
<div class="readout">
<div class="pct" id="pct">0%</div>
<div class="amounts"><b id="raised">$0</b> <span class="of">of</span> <b id="goal">$0</b></div>
</div>
</div>
<div class="footer" id="footer"></div>
</div>
<script src="thermo-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,32 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the fundraiser fields from the URL query string and fills the thermometer.
(function () {
var q = new URLSearchParams(location.search);
var get = function (k) { return (q.get(k) || '').trim(); };
var pct = Math.max(0, Math.min(100, parseFloat(get('pct')) || 0));
var pctLabel = get('pctLabel') || (Math.round(pct) + '%');
var done = pct >= 100;
document.getElementById('campaign').textContent = get('campaign') || 'Fundraiser';
document.getElementById('raised').textContent = get('raised') || '0';
document.getElementById('goal').textContent = get('goal') || '0';
var pctEl = document.getElementById('pct');
pctEl.textContent = pctLabel;
if (done) pctEl.classList.add('done');
var footer = document.getElementById('footer');
if (done) {
footer.className = 'footer done-banner';
footer.textContent = 'Goal reached! 🎉';
} else {
footer.textContent = 'Thank you for your support';
}
// Animate the fill from 0 to pct after first paint.
var fill = document.getElementById('fill');
requestAnimationFrame(function () {
requestAnimationFrame(function () { fill.style.height = pct + '%'; });
});
})();

View file

@ -0,0 +1,170 @@
'use strict';
// Fundraiser "thermometer" -> ScreenTinker PiP overlay.
//
// Reads a tiny JSON progress doc ({ campaign, raised, goal, currency }) from a local
// file or a URL, computes the percentage, and pushes a persistent web overlay showing
// a filling thermometer bar. Re-pushes each poll so the bar updates in place (the player
// keeps a single overlay slot, last-show-wins). Clears the overlay on exit.
//
// node thermo.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
// Currency symbols we render inline; anything else falls back to "CODE 1,234".
const CURRENCY_SYMBOLS = { USD: '$', CAD: '$', AUD: '$', NZD: '$', EUR: '€', GBP: '£', JPY: '¥', INR: '₹' };
// Group an integer with thousands separators without locale surprises.
function groupThousands(n) {
const neg = n < 0;
const digits = String(Math.abs(Math.round(n)));
let out = '';
for (let i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
out += digits[i];
}
return (neg ? '-' : '') + out;
}
// "$12,450" / "€12,450" / "BTC 12,450" (whole units; cents are noise on a wall display).
function formatMoney(amount, currency) {
const code = String(currency || 'USD').toUpperCase();
const sym = CURRENCY_SYMBOLS[code];
const num = groupThousands(Number(amount) || 0);
return sym ? `${sym}${num}` : `${code} ${num}`;
}
// pct is raised/goal clamped to 0..100; pctLabel is the rounded whole-percent string.
// Divide-by-zero-safe: goal <= 0 yields 0%.
function computeProgress({ raised, goal }) {
const r = Number(raised) || 0;
const g = Number(goal) || 0;
let pct = 0;
if (g > 0) pct = (r / g) * 100;
pct = Math.max(0, Math.min(100, pct));
pct = Math.round(pct * 100) / 100; // keep 2dp for a smooth bar fill
return { pct, pctLabel: `${Math.round(pct)}%` };
}
// Raw progress doc -> the fields the overlay displays.
function normalise(data, fallbackCurrency) {
const currency = data.currency || fallbackCurrency || 'USD';
const { pct, pctLabel } = computeProgress(data);
return {
campaign: data.campaign || 'Fundraiser',
raisedLabel: formatMoney(data.raised, currency),
goalLabel: formatMoney(data.goal, currency),
currency,
pct,
pctLabel,
};
}
function overlayUri(base, view) {
const q = new URLSearchParams({
campaign: view.campaign,
raised: view.raisedLabel,
goal: view.goalLabel,
pct: String(view.pct),
pctLabel: view.pctLabel,
currency: view.currency,
});
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
module.exports = { groupThousands, formatMoney, computeProgress, normalise, overlayUri };
// ---- runtime (skipped when imported by the test) ----
if (require.main === module) {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const POLL_SEC = cfg.poll_interval_sec || 60;
const POSITION = cfg.position || 'bottom-left';
const WIDTH = cfg.width || 460;
const HEIGHT = cfg.height || 360;
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
process.exit(1);
}
if (!cfg.source_file && !cfg.source_url) {
console.error('config must set source_file or source_url.');
process.exit(1);
}
let activePip = null;
async function readProgress() {
if (cfg.source_url) {
const res = await fetch(cfg.source_url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`source HTTP ${res.status}`);
return await res.json();
}
const p = path.isAbsolute(cfg.source_file) ? cfg.source_file : path.join(__dirname, cfg.source_file);
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
async function pipShow(view) {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, view),
position: POSITION, width: WIDTH, height: HEIGHT,
duration: 0, border_radius: 16, close_button: false,
title: view.campaign,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear() {
if (!activePip) return;
try {
await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: activePip }),
});
} catch { /* best effort */ }
activePip = null;
}
async function tick() {
try {
const view = normalise(await readProgress(), cfg.currency);
activePip = await pipShow(view);
console.log(`[${new Date().toISOString()}] SHOW "${view.campaign}" ${view.raisedLabel} of ${view.goalLabel} (${view.pctLabel}) pip=${activePip}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] ${e.message}`);
}
}
async function main() {
console.log(`Fundraiser thermometer starting — poll every ${POLL_SEC}s, source=${cfg.source_url || cfg.source_file}`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
console.log('\nclearing overlay before exit...');
await pipClear();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
}

View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,108 @@
# PiP Incident Webhook
An **event-driven** PiP example: a tiny webhook receiver that turns your monitoring
stack's alerts into a floating ScreenTinker overlay — perfect for an engineering wall
TV or NOC screen.
- alert **firing** → red overlay appears (kept until cleared)
- alert **resolved** → overlay disappears
Unlike the CAP / NOAA examples (which *poll* a feed), nothing happens here until your
alerting system **pushes** to `POST /webhook`. Zero runtime dependencies — just Node 18+
(`http` + global `fetch`).
## Payload shapes
It accepts either:
**Generic** (great for `curl`, cron jobs, custom scripts):
```json
{ "status": "firing", "key": "db-down", "title": "Primary DB unreachable", "detail": "conn refused on 5432", "severity": "critical" }
```
**Prometheus Alertmanager** (point a `webhook_config` straight at it):
```json
{ "status": "firing", "alerts": [
{ "status": "firing", "fingerprint": "abc123",
"labels": { "alertname": "HighCPU", "severity": "warning", "instance": "web-1" },
"annotations": { "summary": "CPU > 90%", "description": "web-1 hot for 5m" } }
]}
```
`severity` drives the band colour: `critical`→dark red, `warning`→orange, `info`→amber,
anything else→red. The `key` (or Alertmanager `fingerprint`) is what matches a later
*resolve* back to the overlay it should clear.
## Setup
1. `cp config.example.json config.json` and fill in:
- `api_token` — an `st_` API token with the **`full`** scope.
- `api_base` / `overlay_base_url` — your signage server.
- `device_id` — a device **or** group id.
- `shared_secret` *(optional)* — if set, callers must send it as the `X-Webhook-Secret`
header or `?secret=` query param.
2. **Serve the overlay assets.** The overlay is a `web` PiP rendered in an iframe, so the
player fetches `overlay_base_url` directly. Copy `incident-overlay.html` and
`incident-overlay.js` into the directory your signage server serves at the web root
(e.g. the server's `frontend/` dir) so that `https://<server>/incident-overlay.html`
resolves. They must be **same-origin** with the player (the server CSP only allows
same-origin scripts — that's why the JS is an external `incident-overlay.js`, not inline).
3. `node server.js` (or `npm start`).
## Local quick-start (this repo's dev server)
```bash
cp config.example.json config.json
# edit config.json:
# "api_base": "https://localhost:3443/"
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
# "overlay_base_url": "https://localhost:3443/incident-overlay.html"
# "device_id": "DEVICE_OR_GROUP_ID"
# copy the overlay assets into the server's web root (served same-origin as the player):
cp incident-overlay.html incident-overlay.js ../../frontend/
# self-signed cert on localhost -> let Node accept it:
NODE_TLS_REJECT_UNAUTHORIZED=0 node server.js
```
Then drive it with `curl`:
```bash
# fire a critical incident -> red overlay appears on the player
curl -s localhost:8088/webhook -H 'Content-Type: application/json' -d \
'{"status":"firing","key":"db-down","title":"Primary DB unreachable","detail":"conn refused on 5432","severity":"critical"}'
# ...later, resolve it -> overlay clears
curl -s localhost:8088/webhook -H 'Content-Type: application/json' -d \
'{"status":"resolved","key":"db-down"}'
# health
curl -s localhost:8088/healthz
```
`Ctrl-C` clears any still-showing overlays before exiting.
> Heads-up: this dev box has a shared player. If someone else is demoing on
> `d7c88aa0-…`, point `device_id` at your own device/group instead.
## Wire up Alertmanager
```yaml
# alertmanager.yml
route:
receiver: signage
receivers:
- name: signage
webhook_configs:
- url: http://YOUR_HOST:8088/webhook
send_resolved: true # so "resolved" clears the overlay
```
If you set a `shared_secret`, append it to the URL: `...:8088/webhook?secret=YOUR_SECRET`.
## Test
```bash
npm test # offline; exercises both payload shapes + the colour map
```

View file

@ -0,0 +1,15 @@
{
"listen_port": 8088,
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/incident-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"position": "top-right",
"source_label": "Monitoring",
"shared_secret": null,
"overlay": { "width": 760, "height": 280, "border_radius": 16, "opacity": 1 }
}

View file

@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Incident</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(16px, 3.4vw, 26px); }
.band .pulse { width: 15px; height: 15px; border-radius: 50%; background: rgba(255,255,255,.95);
animation: pulse 1.1s ease-in-out infinite; flex: none; }
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
.band .badge { margin-left: auto; font-size: clamp(11px, 2vw, 14px); font-weight: 700;
letter-spacing: .08em; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.28); }
.body { padding: 16px 24px 18px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.title { font-size: clamp(19px, 4.4vw, 34px); font-weight: 700; line-height: 1.15; }
.detail { font-size: clamp(14px, 2.8vw, 20px); color: #d6d6d6; line-height: 1.3;
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; }
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
</style>
</head>
<body>
<div class="card">
<div class="band" id="band">
<span class="pulse"></span><span id="level">INCIDENT</span>
<span class="badge" id="badge"></span>
</div>
<div class="body">
<div class="title" id="title"></div>
<div class="detail" id="detail"></div>
<div class="footer"><span id="source"></span> <span id="updated"></span></div>
</div>
</div>
<script src="incident-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the incident fields from the URL query string and paints the card.
(function () {
var q = new URLSearchParams(location.search);
var get = function (k) { return (q.get(k) || '').trim(); };
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
document.getElementById('band').style.background = color;
var sev = (get('severity') || 'alert');
document.getElementById('level').textContent = (get('level') || 'INCIDENT').toUpperCase();
document.getElementById('badge').textContent = sev.toUpperCase();
document.getElementById('title').textContent = get('title') || 'Service incident';
document.getElementById('detail').textContent = get('detail') || '';
document.getElementById('source').textContent = get('source') || '';
var updated = get('updated');
if (updated) {
var d = new Date(updated);
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· ' + d.toLocaleString());
}
})();

View file

@ -0,0 +1,12 @@
{
"name": "pip-incident-webhook",
"version": "0.1.0",
"description": "Example: an inbound webhook receiver (Alertmanager / generic) that pushes a red ScreenTinker PiP overlay on incident firing and clears it on resolve.",
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,235 @@
'use strict';
// Event-driven PiP: an inbound webhook receiver. Instead of polling a feed, it waits for
// your monitoring stack to PUSH it incidents, then shows / clears a ScreenTinker PiP overlay
// in real time:
// - status "firing" -> POST /api/pip (red overlay, kept until cleared)
// - status "resolved" -> POST /api/pip/clear
//
// Accepts two payload shapes on POST /webhook:
// (a) generic { status:"firing"|"resolved", key, title, detail, severity }
// (b) Alertmanager{ status, alerts:[{ status, labels:{alertname,severity,...},
// annotations:{summary,description}, fingerprint }] }
//
// node server.js [path/to/config.json]
//
// Node 18+ (built-in http + global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
const http = require('http');
// --- pure logic (unit-tested in test.js; no network) -------------------------------------
// severity -> overlay band colour (#RRGGBB, the PiP colour contract).
const SEV_COLORS = { critical: '7B0000', warning: 'E8730C', info: 'F2C200' };
const DEFAULT_COLOR = 'CC0000';
function colorFor(severity) {
return SEV_COLORS[String(severity || '').toLowerCase()] || DEFAULT_COLOR;
}
// Map "firing"/"resolved" (and Alertmanager's per-alert status) to our two states.
function stateOf(status) {
return String(status || '').toLowerCase() === 'resolved' ? 'resolved' : 'firing';
}
// Normalise either payload shape into a flat list of incidents:
// { key, state:"firing"|"resolved", title, detail, severity }
// `key` is the stable identity used to match a later resolve to its overlay.
function normalise(payload) {
const p = payload || {};
const out = [];
if (Array.isArray(p.alerts)) {
// Alertmanager group webhook. Each alert may carry its own status; fall back to the
// group status. fingerprint is Alertmanager's stable per-alert id.
for (const a of p.alerts) {
const labels = a.labels || {};
const ann = a.annotations || {};
const name = labels.alertname || ann.summary || 'alert';
out.push({
key: a.fingerprint || `${name}:${JSON.stringify(labels.instance || labels.job || '')}`,
state: stateOf(a.status || p.status),
title: ann.summary || name,
detail: ann.description || '',
severity: (labels.severity || 'warning').toLowerCase(),
});
}
return out;
}
// Generic single-incident shape.
const name = p.title || p.key || 'incident';
out.push({
key: p.key || name,
state: stateOf(p.status),
title: p.title || name,
detail: p.detail || '',
severity: (p.severity || 'warning').toLowerCase(),
});
return out;
}
// Build the overlay iframe URL from an incident.
function overlayUri(base, inc, sourceLabel, nowIso) {
const q = new URLSearchParams({
level: 'incident',
title: inc.title || '',
detail: inc.detail || '',
severity: inc.severity || '',
color: colorFor(inc.severity),
source: sourceLabel || '',
updated: nowIso || '',
});
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
module.exports = { colorFor, stateOf, normalise, overlayUri, SEV_COLORS, DEFAULT_COLOR };
// --- server (only when run directly) -----------------------------------------------------
if (require.main === module) {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const PORT = cfg.listen_port || 8088;
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const POSITION = cfg.position || 'top-right';
const SOURCE_LABEL = cfg.source_label || 'Monitoring';
const SECRET = cfg.shared_secret || null;
const OVERLAY = cfg.overlay || {};
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
process.exit(1);
}
// key -> pip_id of the overlay currently showing for that incident.
const active = new Map();
const nowIso = () => new Date().toISOString();
async function pipShow(inc) {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, inc, SOURCE_LABEL, nowIso()),
position: POSITION,
width: OVERLAY.width || 760, height: OVERLAY.height || 280,
duration: 0, // keep until we clear it on resolve
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
close_button: false,
title: inc.title,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear(pipId) {
const res = await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
}
}
async function handleIncidents(incidents) {
const summary = { fired: 0, cleared: 0, skipped: 0 };
for (const inc of incidents) {
if (!inc.key) { summary.skipped++; continue; }
try {
if (inc.state === 'firing') {
if (active.has(inc.key)) { // refresh: clear the old card, show the new
try { await pipClear(active.get(inc.key)); } catch { /* best effort */ }
}
const pipId = await pipShow(inc);
active.set(inc.key, pipId);
summary.fired++;
console.log(`[${nowIso()}] FIRING "${inc.title}" (${inc.severity}) key=${inc.key} pip=${pipId}`);
} else {
const pipId = active.get(inc.key);
if (pipId) {
await pipClear(pipId);
active.delete(inc.key);
summary.cleared++;
console.log(`[${nowIso()}] RESOLVED key=${inc.key} pip=${pipId} (cleared)`);
} else {
summary.skipped++;
console.log(`[${nowIso()}] RESOLVED key=${inc.key} (nothing showing)`);
}
}
} catch (e) {
summary.skipped++;
console.error(`[${nowIso()}] error for key=${inc.key}: ${e.message}`);
}
}
return summary;
}
function authOk(req, url) {
if (!SECRET) return true;
const hdr = req.headers['x-webhook-secret'];
const qs = url.searchParams.get('secret');
return hdr === SECRET || qs === SECRET;
}
function readBody(req, cap = 1_000_000) {
return new Promise((resolve, reject) => {
let n = 0; const chunks = [];
req.on('data', (c) => { n += c.length; if (n > cap) { reject(new Error('body too large')); req.destroy(); } else chunks.push(c); });
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (req.method === 'GET' && url.pathname === '/healthz') {
return send(200, { ok: true, active: active.size });
}
if (req.method !== 'POST' || url.pathname !== '/webhook') {
return send(404, { error: 'POST /webhook or GET /healthz' });
}
if (!authOk(req, url)) return send(401, { error: 'bad or missing shared secret' });
let payload;
try { payload = JSON.parse(await readBody(req) || '{}'); }
catch (e) { return send(400, { error: `invalid JSON: ${e.message}` }); }
const incidents = normalise(payload);
const summary = await handleIncidents(incidents);
send(200, { ok: true, received: incidents.length, ...summary });
});
server.listen(PORT, () => {
console.log(`Incident webhook receiver listening on :${PORT}`);
console.log(` POST /webhook (generic or Alertmanager JSON)${SECRET ? ' [shared secret required]' : ''}`);
console.log(` GET /healthz`);
console.log(` -> device ${DEVICE} @ ${API_BASE}, overlay ${OVERLAY_BASE}, position ${POSITION}`);
});
async function shutdown() {
console.log('\nclearing active overlays before exit...');
for (const pipId of active.values()) { try { await pipClear(pipId); } catch { /* best effort */ } }
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 1500).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}

View file

@ -0,0 +1,55 @@
'use strict';
// Offline unit test for the pure normalise()/colorFor()/overlayUri() logic. No network.
const { normalise, colorFor, overlayUri } = require('./server');
let ok = true;
const check = (cond, msg) => { console.log(`${cond ? '✓' : '✗'} ${msg}`); if (!cond) ok = false; };
// --- generic shape: firing -----------------------------------------------------------------
const gFire = normalise({ status: 'firing', key: 'db-down', title: 'Primary DB unreachable', detail: 'conn refused on 5432', severity: 'critical' });
check(gFire.length === 1, 'generic firing -> 1 incident');
check(gFire[0].key === 'db-down', 'generic key preserved');
check(gFire[0].state === 'firing', 'generic state=firing');
check(gFire[0].title === 'Primary DB unreachable', 'generic title');
check(gFire[0].severity === 'critical', 'generic severity');
// --- generic shape: resolved ---------------------------------------------------------------
const gRes = normalise({ status: 'RESOLVED', key: 'db-down' });
check(gRes[0].state === 'resolved', 'generic resolved (case-insensitive) -> state=resolved');
check(gRes[0].key === 'db-down', 'generic resolved key matches the firing key');
// --- Alertmanager shape: mixed firing + resolved -------------------------------------------
const am = normalise({
status: 'firing',
alerts: [
{ status: 'firing', fingerprint: 'abc123',
labels: { alertname: 'HighCPU', severity: 'warning', instance: 'web-1' },
annotations: { summary: 'CPU > 90%', description: 'web-1 hot for 5m' } },
{ status: 'resolved', fingerprint: 'def456',
labels: { alertname: 'DiskFull', severity: 'critical' },
annotations: { summary: 'Disk 99%', description: '/var almost full' } },
],
});
check(am.length === 2, 'alertmanager -> 2 incidents');
check(am[0].key === 'abc123' && am[0].state === 'firing', 'AM[0] fingerprint key + firing');
check(am[0].title === 'CPU > 90%' && am[0].detail === 'web-1 hot for 5m', 'AM[0] summary/description mapped');
check(am[0].severity === 'warning', 'AM[0] severity from labels');
check(am[1].key === 'def456' && am[1].state === 'resolved', 'AM[1] resolved per-alert status overrides group');
check(am[1].severity === 'critical', 'AM[1] severity critical');
// --- severity -> colour --------------------------------------------------------------------
check(colorFor('critical') === '7B0000', 'colour critical');
check(colorFor('warning') === 'E8730C', 'colour warning');
check(colorFor('info') === 'F2C200', 'colour info');
check(colorFor('weird') === 'CC0000', 'colour default fallback');
check(colorFor() === 'CC0000', 'colour missing -> default');
// --- overlay uri ---------------------------------------------------------------------------
const uri = overlayUri('https://x/incident-overlay.html', am[0], 'Alertmanager', '2026-06-18T10:00:00Z');
check(uri.startsWith('https://x/incident-overlay.html?'), 'uri keeps base + adds query');
check(/color=E8730C/.test(uri), 'uri carries severity colour');
check(/title=CPU/.test(uri), 'uri carries title');
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

3
Examples/PIP-News-Ticker/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,96 @@
# PIP News Ticker
A scrolling RSS/Atom headline ticker pushed to a ScreenTinker screen (or group) via the
PiP overlay API. Polls any feed, extracts the latest headlines, and renders a continuous
right-to-left strip along the bottom of the screen. Keyless and zero-dependency.
```
RSS/Atom feed ──poll──> news.js ──POST /api/pip (type:web)──> player
│ │
parse headlines iframe loads news-overlay.html
join with separator scrolls the strip seamlessly
```
The overlay is **persistent** (`duration: 0`) and refreshed on every poll (the player keeps a
single overlay slot, last-show-wins), so headlines update in place. The ticker is cleared when
you stop the script (Ctrl-C).
## Files
| File | Purpose |
|------|---------|
| `news.js` | Poller + PiP pusher. Hand-rolled RSS/Atom parser (`parseHeadlines`, `feedLabel`). |
| `news-overlay.html` / `news-overlay.js` | The strip overlay. Served same-origin; reads `?text`/`?label`/`?sep`; external JS (no inline) so the server CSP allows it. |
| `config.example.json` | Copy to `config.json` and fill in. |
| `fixture-feed.xml`, `test.js` | Offline test (no network). |
## Setup
1. **Host the overlay.** Copy both overlay files into the signage server's web root so they're
served from the same origin as the player (the server applies `Content-Security-Policy:
script-src 'self'`, which is why the JS is external rather than inline):
```sh
cp news-overlay.html news-overlay.js /path/to/screentinker/frontend/
```
They'll be reachable at `https://<your-server>/news-overlay.html`.
2. **Create an API token** with the `full` scope (PiP is a fleet-affecting, full-trust action).
3. **Configure.** Copy `config.example.json` to `config.json` and set `api_base`, `api_token`,
`overlay_base_url`, `device_id` (a device **or** group id), and your `feed_url`. Optional:
`label` (left chip text; defaults to the feed's channel title), `max_items`, `separator`,
`poll_interval_sec`, and overlay geometry (`position`, `width`, `height`).
4. **Run.**
```sh
npm start # or: node news.js
```
Stop with Ctrl-C to clear the ticker.
## Local quick-start (self-signed dev server)
Against a local ScreenTinker dev instance with a self-signed certificate:
```sh
cp news-overlay.html news-overlay.js /path/to/screentinker/frontend/
cat > config.json <<'JSON'
{
"api_base": "https://localhost:3443/",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://localhost:3443/news-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"feed_url": "https://feeds.bbci.co.uk/news/rss.xml",
"position": "bottom-right",
"width": 1200,
"height": 90,
"poll_interval_sec": 300
}
JSON
NODE_TLS_REJECT_UNAUTHORIZED=0 node news.js
```
`NODE_TLS_REJECT_UNAUTHORIZED=0` is only for trusting the dev box's self-signed cert — don't
use it against production.
## Test
```sh
npm test
```
Runs `test.js` against `fixture-feed.xml` (offline): verifies headline extraction order,
CDATA/entity decoding, `max_items` capping, channel-title labelling, and overlay-URI round-trip.
Prints `RESULT: PASS ✅`.
## Notes
- The parser handles RSS (`<item><title>`) and Atom (`<entry><title>`), decodes CDATA and common
XML entities, and strips stray markup from titles. It's deliberately tolerant rather than a full
XML parser, so it copes with the messy real-world feeds you'll point it at.
- Headline text is rendered with `textContent` only — feed content is never injected as HTML.

View file

@ -0,0 +1,18 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/news-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"feed_url": "https://feeds.bbci.co.uk/news/rss.xml",
"label": null,
"max_items": 12,
"separator": " • ",
"poll_interval_sec": 300,
"position": "bottom-right",
"width": 1200,
"height": 90,
"border_radius": 12,
"opacity": 1
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Demo Newsroom</title>
<link>https://example.com/</link>
<description>Fixture feed for the news ticker offline test</description>
<item>
<title>City council approves new transit line</title>
<link>https://example.com/1</link>
</item>
<item>
<title><![CDATA[Markets rally as <b>tech</b> shares climb]]></title>
<link>https://example.com/2</link>
</item>
<item>
<title>Storms &amp; flooding expected this weekend</title>
<link>https://example.com/3</link>
</item>
<item>
<title>Local team wins championship 3&#8211;2</title>
<link>https://example.com/4</link>
</item>
<item>
<title>Library extends weekend hours</title>
<link>https://example.com/5</link>
</item>
</channel>
</rss>

View file

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>News Ticker</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; overflow: hidden; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.strip { flex: 1; display: flex; align-items: stretch; background: #1a1a1a; color: #fff;
border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.45); }
.chip { display: flex; align-items: center; gap: 10px; padding: 0 18px; background: #CC0000;
font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
font-size: clamp(14px, 2.4vw, 22px); white-space: nowrap; flex: 0 0 auto; }
.chip .pulse { width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,.95);
animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
.viewport { position: relative; flex: 1; overflow: hidden; display: flex; align-items: center; }
.track { position: absolute; white-space: nowrap; will-change: transform;
font-size: clamp(16px, 2.8vw, 26px); font-weight: 600; line-height: 1; }
.track .sep { color: #CC0000; padding: 0 2px; font-weight: 800; }
</style>
</head>
<body>
<div class="strip">
<div class="chip"><span class="pulse"></span><span id="label">NEWS</span></div>
<div class="viewport"><div class="track" id="track"></div></div>
</div>
<script src="news-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,60 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the headline string from the query and scrolls it right-to-left, seamlessly.
(function () {
var q = new URLSearchParams(location.search);
var text = (q.get('text') || '').trim();
var label = (q.get('label') || 'NEWS').trim();
var sep = q.get('sep') || ' • ';
document.getElementById('label').textContent = label;
var track = document.getElementById('track');
var viewport = track.parentNode;
// Build one "run" of the content (separator-joined headlines). Splitting on the
// separator lets us colour the dividers without trusting feed markup (textContent only).
function buildRun(container) {
var parts = text.length ? text.split(sep) : ['(no headlines)'];
parts.forEach(function (p, i) {
if (i > 0) {
var s = document.createElement('span');
s.className = 'sep';
s.textContent = sep;
container.appendChild(s);
}
var span = document.createElement('span');
span.textContent = p;
container.appendChild(span);
});
}
// Two identical runs back-to-back → when the first scrolls fully off, reset by one
// run width for a seamless loop.
buildRun(track);
var gap = document.createElement('span');
gap.textContent = sep;
gap.className = 'sep';
track.appendChild(gap);
var runWidth = 0;
function measureAndStart() {
runWidth = track.scrollWidth; // width of a single run (+ trailing sep)
buildRun(track); // append the second copy for the wrap
var x = viewport.clientWidth; // start just off the right edge
var speed = 90; // px/sec
var last = null;
function frame(ts) {
if (last == null) last = ts;
var dt = (ts - last) / 1000; last = ts;
x -= speed * dt;
if (x <= -runWidth) x += runWidth; // wrap by exactly one run
track.style.transform = 'translateX(' + x + 'px)';
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// Wait a tick so fonts/layout settle before measuring.
if (document.readyState === 'complete') measureAndStart();
else window.addEventListener('load', measureAndStart);
})();

View file

@ -0,0 +1,166 @@
'use strict';
// RSS/Atom headline ticker -> ScreenTinker PiP. Polls a feed, extracts headlines,
// and pushes a persistent scrolling strip overlay to a device/group. Refreshes the
// strip on each poll (player single-slot, last-show-wins) and clears on exit.
//
// node news.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
// Zero dependencies — the feed parser is hand-rolled and tolerant of RSS and Atom.
const fs = require('fs');
const path = require('path');
// ---- pure helpers (exported for the offline test) -------------------------
// Decode CDATA sections and the handful of XML entities feeds actually use.
function decodeText(s) {
if (s == null) return '';
let t = String(s);
// pull CDATA payloads out verbatim
t = t.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
// strip any stray tags (some feeds put markup in titles)
t = t.replace(/<[^>]+>/g, '');
// named + numeric entities
t = t
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
.replace(/&amp;/g, '&'); // ampersand last, so &amp;lt; -> &lt; not <
return t.replace(/\s+/g, ' ').trim();
}
// Grab the first <title>…</title> inside a block (RSS item / Atom entry).
function firstTitle(block) {
const m = block.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
return m ? decodeText(m[1]) : '';
}
// Tolerant headline extraction. Handles RSS (<item>) and Atom (<entry>); falls back
// gracefully if a feed is malformed. Returns up to maxItems non-empty titles in order.
function parseHeadlines(xml, maxItems = 12) {
const text = String(xml || '');
let blocks = text.match(/<item\b[\s\S]*?<\/item>/gi);
if (!blocks || blocks.length === 0) blocks = text.match(/<entry\b[\s\S]*?<\/entry>/gi);
const out = [];
for (const b of blocks || []) {
const title = firstTitle(b);
if (title) out.push(title);
if (out.length >= maxItems) break;
}
return out;
}
// Feed channel/source title, used as the left-hand chip label when present.
function feedLabel(xml) {
const text = String(xml || '');
// RSS: channel > title (the first <title> before any <item>)
const beforeItem = text.split(/<item\b/i)[0];
const ch = beforeItem.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
if (ch) return decodeText(ch[1]);
return '';
}
function buildOverlayUri(base, { text, label, sep }) {
const q = new URLSearchParams({ text: text || '', label: label || '', sep: sep || ' • ' });
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
// ---- live runner ----------------------------------------------------------
function loadConfig() {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
if (!cfg.api_base || !cfg.api_token || !cfg.overlay_base_url || !cfg.device_id || !cfg.feed_url) {
console.error('config must set api_base, api_token, overlay_base_url, device_id, and feed_url.');
process.exit(1);
}
return cfg;
}
async function pipShow(cfg, uri) {
const base = cfg.api_base.replace(/\/$/, '');
const body = {
device_id: cfg.device_id,
type: 'web',
uri,
position: cfg.position || 'bottom-right',
width: cfg.width || 1200,
height: cfg.height || 90,
duration: 0, // persistent until we clear it
border_radius: cfg.border_radius != null ? cfg.border_radius : 12,
opacity: cfg.opacity != null ? cfg.opacity : 1,
};
const res = await fetch(`${base}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear(cfg, pipId) {
const base = cfg.api_base.replace(/\/$/, '');
await fetch(`${base}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
body: JSON.stringify({ device_id: cfg.device_id, pip_id: pipId }),
}).catch(() => {});
}
async function main() {
const cfg = loadConfig();
const maxItems = cfg.max_items || 12;
const sep = cfg.separator || ' • ';
const pollSec = cfg.poll_interval_sec || 300;
let currentPip = null;
console.log(`News ticker starting — feed=${cfg.feed_url}`);
console.log(` poll: every ${pollSec}s max headlines: ${maxItems} target: ${cfg.device_id}`);
async function tick() {
let xml;
try {
const res = await fetch(cfg.feed_url, { headers: { Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' } });
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
xml = await res.text();
} catch (e) {
console.error(`[${new Date().toISOString()}] feed fetch error: ${e.message}`);
return;
}
const headlines = parseHeadlines(xml, maxItems);
if (headlines.length === 0) { console.error(`[${new Date().toISOString()}] no headlines parsed`); return; }
const label = cfg.label || feedLabel(xml) || 'NEWS';
const text = headlines.join(sep);
const uri = buildOverlayUri(cfg.overlay_base_url, { text, label, sep });
try {
currentPip = await pipShow(cfg, uri);
console.log(`[${new Date().toISOString()}] SHOW ${headlines.length} headline(s) pip=${currentPip}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
}
}
await tick();
const timer = setInterval(tick, pollSec * 1000);
async function shutdown() {
clearInterval(timer);
if (currentPip) { console.log('\nclearing ticker before exit...'); await pipClear(cfg, currentPip); }
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
if (require.main === module) main();
module.exports = { decodeText, firstTitle, parseHeadlines, feedLabel, buildOverlayUri };

View file

@ -0,0 +1,12 @@
{
"name": "pip-news-ticker",
"version": "0.1.0",
"description": "Example: scroll RSS/Atom headlines across a ScreenTinker screen via the PiP API.",
"type": "commonjs",
"main": "news.js",
"scripts": {
"start": "node news.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,44 @@
'use strict';
// Offline test for the news-ticker parser. No network, no PiP push.
const fs = require('fs');
const path = require('path');
const { parseHeadlines, feedLabel, decodeText, buildOverlayUri } = require('./news');
const xml = fs.readFileSync(path.join(__dirname, 'fixture-feed.xml'), 'utf8');
let pass = true;
const checks = [];
function check(name, cond, got) {
checks.push({ name, cond, got });
if (!cond) pass = false;
}
const all = parseHeadlines(xml, 12);
check('extracts all 5 items', all.length === 5, all.length);
check('order preserved (#1)', all[0] === 'City council approves new transit line', all[0]);
check('CDATA decoded + tags stripped', all[1] === 'Markets rally as tech shares climb', all[1]);
check('ampersand entity decoded', all[2] === 'Storms & flooding expected this weekend', all[2]);
check('numeric entity () decoded', all[3] === 'Local team wins championship 32', all[3]);
check('last item present', all[4] === 'Library extends weekend hours', all[4]);
const capped = parseHeadlines(xml, 3);
check('max_items caps the list', capped.length === 3, capped.length);
const label = feedLabel(xml);
check('channel title used as label', label === 'Demo Newsroom', label);
// decodeText: ampersand applied last so escaped entities survive
check('escaped &lt; survives', decodeText('a &amp;lt; b') === 'a &lt; b', decodeText('a &amp;lt; b'));
// uri round-trips through URLSearchParams
const uri = buildOverlayUri('https://signage.example.com/news-overlay.html', {
text: 'A • B & C', label: 'NEWS', sep: ' • ',
});
const parsed = new URLSearchParams(uri.split('?')[1]);
check('uri text round-trips', parsed.get('text') === 'A • B & C', parsed.get('text'));
check('uri label round-trips', parsed.get('label') === 'NEWS', parsed.get('label'));
check('uri uses ? join once', (uri.match(/\?/g) || []).length === 1, uri);
for (const c of checks) console.log(`${c.cond ? '✓' : '✗'} ${c.name}${c.cond ? '' : ` (got: ${JSON.stringify(c.got)})`}`);
console.log(`\nRESULT: ${pass ? 'PASS ✅' : 'FAIL ❌'}`);
process.exit(pass ? 0 : 1);

3
Examples/PIP-QR-Rotator/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,110 @@
# PiP QR Rotator
Rotate **scannable QR codes** through a corner of your ScreenTinker screens via the PiP
API — Guest Wi-Fi, the lunch menu, a feedback survey, a "scan to download" link, the event
schedule, a checkout/tip link… anything a phone camera should grab.
The QR codes are generated **client-side, in the overlay itself** — no QR web service, no
image hosting, no external libraries, no network calls. That keeps it fast, private, and
compliant with the player's Content-Security-Policy (`script-src 'self'`).
```
qr.js --(POST /api/pip, type:web)--> player
uri = qr-overlay.html?data=<payload>&label=<caption>
|
qr-overlay.js encodes <payload> into a QR matrix and paints it on a <canvas>
every rotate_interval_sec, qr.js pushes the next entry (player = last-show-wins)
```
## Files
| File | Purpose |
|------|---------|
| `qr.js` | Rotates through `config.entries`, pushing each as a PiP overlay. `--clear` removes it. |
| `qr-overlay.html` / `qr-overlay.js` | The overlay page the player loads in an iframe. **Generates the QR client-side.** Must be served by your ScreenTinker host (same-origin with the player). |
| `config.example.json` | Copy to `config.json` and fill in. |
| `test.js` | Offline unit test (`npm test`) — pure helpers + the QR encoder's Reed-Solomon core. |
## Setup
1. **Mint a token.** In the dashboard create an API token with the **`full`** scope (PiP
is fleet-affecting and renders web content, so it requires `full`).
2. **Serve the overlay assets.** Copy `qr-overlay.html` and `qr-overlay.js` into the
directory your ScreenTinker server serves at the web root (its `frontend/` dir), so they
live at `https://<your-host>/qr-overlay.html`. They **must** be same-origin with the
player — the server applies a CSP that only allows same-origin scripts, which is exactly
why the QR is drawn by `qr-overlay.js` (no CDN).
3. **Configure.** `cp config.example.json config.json` and set `api_base`, `api_token`,
`overlay_base_url` (the URL from step 2), `device_id` (a device **or** a group id), and
your `entries`.
4. **Run.** `node qr.js` — it pushes the first code immediately, then rotates every
`rotate_interval_sec`. `Ctrl-C` clears the overlay.
## Configuration
| Key | Meaning |
|-----|---------|
| `entries` | Array of `{ label, data }`. `data` is the QR payload (required); `label` is the caption shown under it. |
| `rotate_interval_sec` | Seconds between entries (default `15`). A single entry just stays up. |
| `position` | `top-left`, `top-right`, `bottom-left`, `bottom-right` (default), or `center`. |
| `width` / `height` | Overlay box px (default `360` × `420` — tall so the caption fits under the code). |
| `border_radius`, `opacity` | Optional overlay styling. |
### QR payload cookbook
| Use | `data` value |
|-----|--------------|
| Open a link | `https://example.com/menu` |
| **Join Wi-Fi** (auto-connect) | `WIFI:T:WPA;S:<ssid>;P:<password>;;` — for an open network use `WIFI:T:nopass;S:<ssid>;;` |
| Pre-filled email | `mailto:hi@example.com?subject=Feedback` |
| Phone number | `tel:+15551234567` |
| Plain text | any text |
> Wi-Fi note: special characters in the SSID/password (`\ ; , : "`) must be backslash-escaped
> per the Wi-Fi QR spec, e.g. `P:p\;w\:d`.
## Local quick-start (this repo)
The local ScreenTinker instance serves on `https://localhost:3443/` (self-signed) and the
registered player is device `DEVICE_OR_GROUP_ID`.
```bash
# from the repo root: serve the overlay assets same-origin with the player
cp Examples/PIP-QR-Rotator/qr-overlay.html Examples/PIP-QR-Rotator/qr-overlay.js frontend/
# then in this dir:
cp config.example.json config.json
# edit config.json:
# "api_base": "https://localhost:3443/"
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
# "overlay_base_url": "https://localhost:3443/qr-overlay.html"
# "device_id": "DEVICE_OR_GROUP_ID"
# self-signed cert -> let Node accept it for this run
NODE_TLS_REJECT_UNAUTHORIZED=0 node qr.js
```
## Testing
```bash
npm test
```
Runs offline (no network, no player): validates the rotation/URL helpers and verifies the
embedded QR encoder's Reed-Solomon math against the published QR generator polynomials, plus
structural checks (finder/timing patterns, version sizing). For the real proof, point it at
a screen and **scan it with your phone**.
## Notes & limits
- The encoder is a compact **byte-mode** implementation of the QR spec (ISO/IEC 18004),
based on Nayuki's reference algorithm (MIT). Byte mode handles any UTF-8 payload; it
auto-selects the smallest version and the best mask, and boosts the error-correction level
for free when there's spare capacity (more robust scanning).
- Keep payloads reasonably short for at-a-distance scanning — long URLs make a denser code.
Use a link shortener for long destinations.
- Like all PiP overlays, this is **ephemeral**: a player reboot drops it (re-run to restore),
and the script clears it on `Ctrl-C`.

View file

@ -0,0 +1,18 @@
{
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/qr-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"rotate_interval_sec": 15,
"position": "bottom-right",
"width": 360,
"height": 420,
"entries": [
{ "label": "Guest Wi-Fi", "data": "WIFI:T:WPA;S:Lobby-Guest;P:welcome123;;" },
{ "label": "Today's Lunch Menu", "data": "https://example.com/menu" },
{ "label": "Tell us how we're doing", "data": "https://example.com/survey" }
]
}

View file

@ -0,0 +1,12 @@
{
"name": "pip-qr-rotator",
"version": "0.1.0",
"description": "Example: rotate scannable QR codes (Wi-Fi, menu, survey, links) on ScreenTinker screens via the PiP API. QR codes are generated client-side — no network, no dependencies.",
"type": "commonjs",
"main": "qr.js",
"scripts": {
"start": "node qr.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scan Me</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; background: #14161c; color: #fff; border-radius: 16px; overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 22px; box-sizing: border-box; }
.panel { background: #fff; border-radius: 14px; padding: 14px; line-height: 0;
box-shadow: 0 4px 16px rgba(0,0,0,.25); }
.panel canvas { display: block; width: clamp(160px, 46vh, 360px); height: auto; image-rendering: pixelated; }
.label { font-size: clamp(16px, 4.5vw, 28px); font-weight: 700; text-align: center; line-height: 1.15;
max-width: 95%; }
.hint { font-size: clamp(11px, 2.6vw, 15px); color: #9aa0aa; letter-spacing: .14em; text-transform: uppercase; }
.placeholder { color: #9aa0aa; font-size: clamp(14px, 4vw, 22px); text-align: center; padding: 30px;
display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div class="card">
<div class="panel"><canvas id="qr"></canvas></div>
<div class="label" id="label"></div>
<div class="hint">📷 Scan with your camera</div>
<div class="placeholder" id="placeholder" style="display:none">No QR data</div>
</div>
<script src="qr-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,361 @@
// QR Rotator overlay — generates the QR code CLIENT-SIDE, with NO network calls and NO
// external libraries, so it satisfies the player's CSP (scriptSrc 'self') and works
// fully offline. Reads ?data (the QR payload) and ?label (caption) from the URL.
//
// The encoder is a compact byte-mode implementation of the QR Code spec (ISO/IEC 18004),
// based on Nayuki's "QR Code generator" reference algorithm (MIT License). Byte mode is
// used for everything, so any UTF-8 payload works (URLs, WIFI: strings, plain text).
//
// It also exports its internals via module.exports when require()'d in Node, so the
// offline test can verify the Reed-Solomon / encoder core without needing a decoder.
(function (global) {
'use strict';
// ---------- GF(256) arithmetic & Reed-Solomon (Nayuki) ----------
function rsMul(x, y) {
var z = 0;
for (var i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11D);
z ^= ((y >>> i) & 1) * x;
}
return z & 0xFF;
}
function rsDivisor(degree) {
if (degree < 1 || degree > 255) throw new RangeError('degree out of range');
var result = [];
for (var i = 0; i < degree - 1; i++) result.push(0);
result.push(1);
var root = 1;
for (i = 0; i < degree; i++) {
for (var j = 0; j < result.length; j++) {
result[j] = rsMul(result[j], root);
if (j + 1 < result.length) result[j] ^= result[j + 1];
}
root = rsMul(root, 0x02);
}
return result;
}
function rsRemainder(data, divisor) {
var result = divisor.map(function () { return 0; });
for (var k = 0; k < data.length; k++) {
var factor = data[k] ^ result.shift();
result.push(0);
for (var i = 0; i < divisor.length; i++) result[i] ^= rsMul(divisor[i], factor);
}
return result;
}
// ---------- spec tables: [ecl 0..3 = L,M,Q,H][version 1..40] ----------
var ECC_CW = [
[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],
[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],
[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],
[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]
];
var ECC_BLOCKS = [
[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],
[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],
[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],
[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]
];
var ECL_FORMAT = [1, 0, 3, 2]; // 2-bit format value for L,M,Q,H
var ECL_INDEX = { L: 0, M: 1, Q: 2, H: 3 };
function numRawDataModules(ver) {
var result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
var numAlign = Math.floor(ver / 7) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7) result -= 36;
}
return result;
}
function numDataCodewords(ver, ecl) {
return Math.floor(numRawDataModules(ver) / 8) - ECC_CW[ecl][ver] * ECC_BLOCKS[ecl][ver];
}
function alignmentPositions(ver) {
if (ver === 1) return [];
var numAlign = Math.floor(ver / 7) + 2;
var step = (ver === 32) ? 26 : Math.ceil((ver * 4 + 4) / (numAlign * 2 - 2)) * 2;
var size = ver * 4 + 17;
var result = [6];
for (var pos = size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
return result;
}
function getBit(x, i) { return ((x >>> i) & 1) !== 0; }
// UTF-8 bytes for a string, dependency-free (TextEncoder when present).
function utf8Bytes(str) {
if (typeof TextEncoder !== 'undefined') return Array.from(new TextEncoder().encode(str));
var out = [];
for (var i = 0; i < str.length; i++) {
var c = str.charCodeAt(i);
if (c < 0x80) out.push(c);
else if (c < 0x800) { out.push(0xC0 | (c >> 6), 0x80 | (c & 0x3F)); }
else { out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)); }
}
return out;
}
// ---------- encode bytes -> { size, modules } ----------
function encodeBytes(dataBytes, eclName) {
var ecl = ECL_INDEX[eclName] != null ? ECL_INDEX[eclName] : 1;
// smallest version that fits
var ver;
for (ver = 1; ; ver++) {
if (ver > 40) throw new RangeError('Data too long to fit in any QR version');
var ccbits = ver <= 9 ? 8 : 16;
var usedBits = 4 + ccbits + dataBytes.length * 8;
if (usedBits <= numDataCodewords(ver, ecl) * 8) break;
}
// boost ECC level for free if it still fits at this version
[1, 2, 3].forEach(function (newEcl) {
var ccbits = ver <= 9 ? 8 : 16;
var usedBits = 4 + ccbits + dataBytes.length * 8;
if (newEcl > ecl && usedBits <= numDataCodewords(ver, newEcl) * 8) ecl = newEcl;
});
// build bit buffer
var bb = [];
function appendBits(val, len) { for (var i = len - 1; i >= 0; i--) bb.push((val >>> i) & 1); }
appendBits(0x4, 4); // byte mode indicator
appendBits(dataBytes.length, ver <= 9 ? 8 : 16); // char count
for (var i = 0; i < dataBytes.length; i++) appendBits(dataBytes[i], 8);
var capacityBits = numDataCodewords(ver, ecl) * 8;
appendBits(0, Math.min(4, capacityBits - bb.length)); // terminator
appendBits(0, (8 - bb.length % 8) % 8); // byte align
for (var pad = 0xEC; bb.length < capacityBits; pad ^= 0xEC ^ 0x11) appendBits(pad, 8);
var dataCodewords = [];
for (i = 0; i < bb.length; i += 8) {
var b = 0;
for (var j = 0; j < 8; j++) b = (b << 1) | bb[i + j];
dataCodewords.push(b);
}
var allCodewords = addEccAndInterleave(dataCodewords, ver, ecl);
return buildMatrix(allCodewords, ver, ecl);
}
function addEccAndInterleave(data, ver, ecl) {
var numBlocks = ECC_BLOCKS[ecl][ver];
var blockEccLen = ECC_CW[ecl][ver];
var rawCodewords = Math.floor(numRawDataModules(ver) / 8);
var numShortBlocks = numBlocks - rawCodewords % numBlocks;
var shortBlockLen = Math.floor(rawCodewords / numBlocks);
var blocks = [];
var divisor = rsDivisor(blockEccLen);
for (var i = 0, k = 0; i < numBlocks; i++) {
var dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
k += dat.length;
var ecc = rsRemainder(dat, divisor);
if (i < numShortBlocks) dat = dat.concat([0]);
blocks.push(dat.concat(ecc));
}
var result = [];
for (i = 0; i < blocks[0].length; i++) {
for (var j = 0; j < blocks.length; j++) {
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(blocks[j][i]);
}
}
return result;
}
function buildMatrix(allCodewords, ver, ecl) {
var size = ver * 4 + 17;
var modules = [], isFunc = [];
for (var i = 0; i < size; i++) { modules.push(new Array(size).fill(false)); isFunc.push(new Array(size).fill(false)); }
function set(x, y, dark) { if (x >= 0 && x < size && y >= 0 && y < size) { modules[y][x] = dark; isFunc[y][x] = true; } }
// timing patterns
for (i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
// finder patterns + separators
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(function (c) {
for (var dy = -4; dy <= 4; dy++) for (var dx = -4; dx <= 4; dx++) {
var dist = Math.max(Math.abs(dx), Math.abs(dy));
set(c[0] + dx, c[1] + dy, dist !== 2 && dist !== 4);
}
});
// alignment patterns
var ap = alignmentPositions(ver), n = ap.length;
for (i = 0; i < n; i++) for (var j = 0; j < n; j++) {
if ((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0)) continue;
for (var dy = -2; dy <= 2; dy++) for (var dx = -2; dx <= 2; dx++) {
set(ap[j] + dx, ap[i] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
function drawFormat(mask) {
var data = (ECL_FORMAT[ecl] << 3) | mask;
var rem = data;
for (var i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
var bits = ((data << 10) | rem) ^ 0x5412;
for (i = 0; i <= 5; i++) set(8, i, getBit(bits, i));
set(8, 7, getBit(bits, 6)); set(8, 8, getBit(bits, 7)); set(7, 8, getBit(bits, 8));
for (i = 9; i < 15; i++) set(14 - i, 8, getBit(bits, i));
for (i = 0; i < 8; i++) set(size - 1 - i, 8, getBit(bits, i));
for (i = 8; i < 15; i++) set(8, size - 15 + i, getBit(bits, i));
set(8, size - 8, true); // always-dark module
}
function drawVersion() {
if (ver < 7) return;
var rem = ver;
for (var i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
var bits = (ver << 12) | rem;
for (i = 0; i < 18; i++) {
var bit = getBit(bits, i);
var a = size - 11 + i % 3, b = Math.floor(i / 3);
set(a, b, bit); set(b, a, bit);
}
}
drawFormat(0); // reserve the format areas as function modules
drawVersion();
// draw data + ecc codewords (zigzag, bottom-right -> up)
var bitIdx = 0;
for (var right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (var vert = 0; vert < size; vert++) {
for (var c2 = 0; c2 < 2; c2++) {
var x = right - c2;
var upward = ((right + 1) & 2) === 0;
var y = upward ? size - 1 - vert : vert;
if (!isFunc[y][x] && bitIdx < allCodewords.length * 8) {
modules[y][x] = getBit(allCodewords[bitIdx >>> 3], 7 - (bitIdx & 7));
bitIdx++;
}
}
}
}
// choose the mask with the lowest penalty, then apply it for real
function applyMask(mask) {
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) {
if (isFunc[y][x]) continue;
var invert;
switch (mask) {
case 0: invert = (x + y) % 2 === 0; break;
case 1: invert = y % 2 === 0; break;
case 2: invert = x % 3 === 0; break;
case 3: invert = (x + y) % 3 === 0; break;
case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
case 5: invert = (x * y) % 2 + (x * y) % 3 === 0; break;
case 6: invert = ((x * y) % 2 + (x * y) % 3) % 2 === 0; break;
case 7: invert = ((x + y) % 2 + (x * y) % 3) % 2 === 0; break;
}
if (invert) modules[y][x] = !modules[y][x];
}
}
var best = -1, minPenalty = Infinity;
for (var mask = 0; mask < 8; mask++) {
drawFormat(mask); applyMask(mask);
var p = penalty(modules, size);
if (p < minPenalty) { minPenalty = p; best = mask; }
applyMask(mask); // undo (XOR is its own inverse)
}
drawFormat(best); applyMask(best);
return { size: size, modules: modules, version: ver, ecl: ecl };
}
// ---------- mask penalty (Nayuki getPenaltyScore) ----------
function penalty(modules, size) {
var N1 = 3, N2 = 3, N3 = 40, N4 = 10, result = 0;
function countPatterns(rh) {
var nn = rh[1];
var core = nn > 0 && rh[2] === nn && rh[3] === nn * 3 && rh[4] === nn && rh[5] === nn;
return (core && rh[0] >= nn * 4 && rh[6] >= nn ? 1 : 0) + (core && rh[6] >= nn * 4 && rh[0] >= nn ? 1 : 0);
}
function addHistory(run, rh) { if (rh[0] === 0) run += size; rh.pop(); rh.unshift(run); }
function terminate(color, run, rh) {
if (color) { addHistory(run, rh); run = 0; }
run += size; addHistory(run, rh);
return countPatterns(rh);
}
// rows
for (var y = 0; y < size; y++) {
var color = false, run = 0, rh = [0, 0, 0, 0, 0, 0, 0];
for (var x = 0; x < size; x++) {
if (modules[y][x] === color) { run++; if (run === 5) result += N1; else if (run > 5) result++; }
else { addHistory(run, rh); if (!color) result += countPatterns(rh) * N3; color = modules[y][x]; run = 1; }
}
result += terminate(color, run, rh) * N3;
}
// columns
for (var x2 = 0; x2 < size; x2++) {
var color2 = false, run2 = 0, rh2 = [0, 0, 0, 0, 0, 0, 0];
for (var y2 = 0; y2 < size; y2++) {
if (modules[y2][x2] === color2) { run2++; if (run2 === 5) result += N1; else if (run2 > 5) result++; }
else { addHistory(run2, rh2); if (!color2) result += countPatterns(rh2) * N3; color2 = modules[y2][x2]; run2 = 1; }
}
result += terminate(color2, run2, rh2) * N3;
}
// 2x2 blocks
for (var yy = 0; yy < size - 1; yy++) for (var xx = 0; xx < size - 1; xx++) {
var c = modules[yy][xx];
if (c === modules[yy][xx + 1] && c === modules[yy + 1][xx] && c === modules[yy + 1][xx + 1]) result += N2;
}
// dark proportion
var dark = 0;
for (var a = 0; a < size; a++) for (var b = 0; b < size; b++) if (modules[a][b]) dark++;
var total = size * size;
var k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
result += k * N4;
return result;
}
var QR = { rsMul: rsMul, rsDivisor: rsDivisor, rsRemainder: rsRemainder, encodeBytes: encodeBytes, utf8Bytes: utf8Bytes, numDataCodewords: numDataCodewords };
if (typeof module !== 'undefined' && module.exports) module.exports = QR;
else global.QR = QR;
// ---------- browser rendering ----------
if (typeof document === 'undefined') return;
function draw() {
var q = new URLSearchParams(location.search);
var data = q.get('data') || '';
var label = (q.get('label') || '').trim();
var labelEl = document.getElementById('label');
if (labelEl) labelEl.textContent = label;
var canvas = document.getElementById('qr');
var placeholder = document.getElementById('placeholder');
if (!data) { show(placeholder); hide(canvas); return; }
try {
var qr = encodeBytes(utf8Bytes(data), 'M');
paint(canvas, qr);
show(canvas); hide(placeholder);
} catch (e) {
if (placeholder) placeholder.textContent = 'QR error: ' + (e && e.message ? e.message : e);
show(placeholder); hide(canvas);
}
}
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function paint(canvas, qr) {
if (!canvas) return;
var quiet = 4;
var dim = qr.size + quiet * 2;
var scale = Math.max(2, Math.floor(560 / dim)); // crisp internal resolution
canvas.width = dim * scale;
canvas.height = dim * scale;
var ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000000';
for (var y = 0; y < qr.size; y++) for (var x = 0; x < qr.size; x++) {
if (qr.modules[y][x]) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', draw);
else draw();
})(typeof globalThis !== 'undefined' ? globalThis : this);

View file

@ -0,0 +1,153 @@
'use strict';
// QR Rotator -> ScreenTinker PiP. Cycles through a list of {label, data} entries,
// pushing each as a PiP web overlay that renders the QR code CLIENT-SIDE (the encoder
// lives in qr-overlay.js — no network, no external libraries, CSP-safe). Every
// `rotate_interval_sec` it shows the next entry; the player keeps a single overlay slot
// (last-show-wins) so each push replaces the previous one. Cleared on exit.
//
// node qr.js [path/to/config.json]
// node qr.js [config] --clear # remove the overlay and exit
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
//
// Good for: guest Wi-Fi join, lunch menu, feedback survey, ticket/checkout links,
// "scan to download the app", event schedule — anything a phone camera should grab.
const fs = require('fs');
const path = require('path');
// --- pure, testable helpers (no I/O) ---
// Keep only well-formed entries: `data` is required (the QR payload); `label` is
// optional caption text. Returns { entries, errors } so the caller can warn and proceed.
function validateEntries(raw) {
const entries = [];
const errors = [];
if (!Array.isArray(raw)) return { entries, errors: ['"entries" must be an array'] };
raw.forEach((e, i) => {
if (!e || typeof e !== 'object') { errors.push(`entry ${i}: not an object`); return; }
const data = typeof e.data === 'string' ? e.data.trim() : '';
if (!data) { errors.push(`entry ${i}: missing "data"`); return; }
entries.push({ label: typeof e.label === 'string' ? e.label : '', data });
});
return { entries, errors };
}
// Build the overlay URL with the QR payload + caption in the query string.
function overlayUri(overlayBase, entry) {
const q = new URLSearchParams({ data: entry.data || '', label: entry.label || '' });
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
}
// Advance the rotation index, wrapping around the list.
function nextIndex(i, len) {
if (!len || len < 1) return 0;
return (i + 1) % len;
}
module.exports = { validateEntries, overlayUri, nextIndex };
// --- CLI ---
if (require.main === module) main();
function main() {
const args = process.argv.slice(2);
const clear = args.includes('--clear');
const positional = args.filter(a => !a.startsWith('--'));
const cfgPath = positional[0] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
const apiToken = cfg.api_token;
const overlayBase = cfg.overlay_base_url;
const deviceId = cfg.device_id;
if (!apiBase || !apiToken || !deviceId) {
console.error('config must set api_base, api_token, and device_id.');
process.exit(1);
}
if (clear) return doClear(apiBase, apiToken, deviceId);
if (!overlayBase) { console.error('config must set overlay_base_url (where qr-overlay.html is served).'); process.exit(1); }
const { entries, errors } = validateEntries(cfg.entries);
for (const err of errors) console.warn(`skipping ${err}`);
if (entries.length === 0) { console.error('config.entries has no valid entries (each needs a "data" string).'); process.exit(1); }
const intervalSec = cfg.rotate_interval_sec || 15;
const position = cfg.position || 'bottom-right';
const width = cfg.width || 360;
const height = cfg.height || 420;
const opacity = cfg.opacity != null ? cfg.opacity : 1;
const borderRadius = cfg.border_radius != null ? cfg.border_radius : 16;
console.log(`QR rotator starting — ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, every ${intervalSec}s, position ${position}`);
entries.forEach((e, i) => console.log(` ${i + 1}. ${e.label || '(no label)'} -> ${e.data.slice(0, 60)}${e.data.length > 60 ? '…' : ''}`));
const opts = { apiBase, apiToken, deviceId, overlayBase, position, width, height, opacity, borderRadius };
let idx = 0;
let lastPip = null;
async function show() {
const entry = entries[idx];
try {
lastPip = await pipShow(opts, entry);
console.log(`[${new Date().toISOString()}] SHOW ${idx + 1}/${entries.length} "${entry.label || '(no label)'}" pip=${lastPip}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
}
idx = nextIndex(idx, entries.length);
}
show();
const timer = entries.length > 1 ? setInterval(show, intervalSec * 1000) : null;
async function shutdown() {
if (timer) clearInterval(timer);
console.log('\nclearing overlay before exit...');
try { await doClear(apiBase, apiToken, deviceId, true); } catch { /* best effort */ }
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
async function pipShow(opts, entry) {
const body = {
device_id: opts.deviceId,
type: 'web',
uri: overlayUri(opts.overlayBase, entry),
position: opts.position,
width: opts.width,
height: opts.height,
duration: 0, // persistent; we replace/clear it ourselves
opacity: opts.opacity,
border_radius: opts.borderRadius,
close_button: false,
title: (entry.label || '').slice(0, 200),
};
const res = await fetch(`${opts.apiBase}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiToken}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
return json.pip_id;
}
async function doClear(apiBase, apiToken, deviceId, quiet) {
const res = await fetch(`${apiBase}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
body: JSON.stringify({ device_id: deviceId }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
if (!quiet) console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
}

View file

@ -0,0 +1,72 @@
'use strict';
// Offline test. No network, no player. Covers:
// - qr.js pure helpers (entry validation, overlay-uri build/round-trip, rotation wrap)
// - the embedded QR encoder's Reed-Solomon core, checked against the published QR
// generator polynomials (degree 7 and 10) — this catches GF(256) math errors without
// needing a QR decoder — plus structural invariants of a generated matrix.
const { validateEntries, overlayUri, nextIndex } = require('./qr');
const QR = require('./qr-overlay');
let ok = true;
function check(name, cond) { console.log(`${cond ? '•' : '✗'} ${name}`); if (!cond) ok = false; }
// ---- qr.js pure helpers ----
const v = validateEntries([
{ label: 'A', data: 'https://x.test/1' },
{ label: 'B', data: ' ' }, // blank -> rejected
{ data: 'WIFI:T:WPA;S:Net;P:pw;;' }, // no label -> ok, label defaults to ''
{ label: 'C' }, // no data -> rejected
]);
check('validateEntries keeps the 2 valid entries', v.entries.length === 2);
check('validateEntries reports the 2 bad entries', v.errors.length === 2);
check('validateEntries defaults missing label to ""', v.entries[1].label === '');
check('validateEntries non-array -> error', validateEntries('nope').errors.length === 1);
const entry = { label: 'Guest Wi-Fi & More', data: 'WIFI:T:WPA;S:Lobby Guest;P:p@ss=1;;' };
const uri = overlayUri('https://s.example.com/qr-overlay.html', entry);
const back = new URLSearchParams(uri.split('?')[1]);
check('overlayUri round-trips data exactly', back.get('data') === entry.data);
check('overlayUri round-trips label exactly', back.get('label') === entry.label);
check('overlayUri encodes (no raw spaces/&/;)', !/[ &;]/.test(uri.split('?')[1].replace(/&data=|&label=/, '')));
check('overlayUri joins with & when base already has ?',
overlayUri('https://s/x?a=1', { data: 'd' }).includes('?a=1&'));
check('nextIndex wraps around', nextIndex(2, 3) === 0 && nextIndex(0, 3) === 1 && nextIndex(1, 3) === 2);
check('nextIndex guards empty list', nextIndex(0, 0) === 0);
// ---- Reed-Solomon core vs published QR generator polynomials ----
// Build GF(256) exp/log tables from the encoder's own multiply, then convert the computed
// divisor coefficients back to alpha-exponent form to compare with the spec's values.
const exp = new Array(256), log = new Array(256);
exp[0] = 1;
for (let i = 1; i < 256; i++) exp[i] = QR.rsMul(exp[i - 1], 2);
for (let i = 0; i < 255; i++) log[exp[i]] = i;
function toAlpha(coeffs) { return coeffs.map((c) => log[c]); }
// Published non-leading generator-polynomial exponents (Thonky / ISO 18004 Annex A).
const GEN7 = [87, 229, 146, 149, 238, 102, 21];
const GEN10 = [251, 67, 46, 61, 118, 70, 64, 94, 32, 45];
const d7 = toAlpha(QR.rsDivisor(7));
const d10 = toAlpha(QR.rsDivisor(10));
check('RS generator poly (deg 7) matches spec', JSON.stringify(d7) === JSON.stringify(GEN7));
check('RS generator poly (deg 10) matches spec', JSON.stringify(d10) === JSON.stringify(GEN10));
// ---- encoder structural invariants ----
const tiny = QR.encodeBytes(QR.utf8Bytes('hi'), 'M'); // tiny -> version 1
check('tiny payload -> 21x21 (version 1)', tiny.size === 21 && tiny.modules.length === 21);
// finder patterns: dark outer ring at the three corners, white separator beside them.
check('top-left finder corner dark', tiny.modules[0][0] === true);
check('top-left separator light', tiny.modules[0][7] === false);
check('top-left finder centre dark', tiny.modules[3][3] === true);
check('top-right finder present', tiny.modules[0][tiny.size - 1] === true);
check('bottom-left finder present', tiny.modules[tiny.size - 1][0] === true);
// timing pattern alternates along row/col 6
check('timing pattern alternates', tiny.modules[6][8] !== tiny.modules[6][9]);
const url = QR.encodeBytes(QR.utf8Bytes('https://example.com/menu'), 'M');
check('longer URL bumps the version (size > 21)', url.size > 21 && (url.size - 17) % 4 === 0);
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,3 @@
config.json
node_modules/
package-lock.json

View file

@ -0,0 +1,107 @@
# Room Status sign (calendar-driven Available / Busy)
Turns a ScreenTinker display into a meeting-room sign. It polls an **ICS calendar
feed** and pushes a [PiP](../../docs) web overlay that shows **AVAILABLE** (green) or
**BUSY** (red) plus the current/next meeting time. Re-pushed every poll so the state
stays fresh; cleared when you stop the script.
No dependencies — Node 18+ only.
## How it works
```
ICS feed ──poll──> room.js ──POST /api/pip (type=web)──> player renders room-overlay.html
```
- `room.js` fetches the calendar, parses VEVENTs, and decides busy/free at *now*.
- The overlay is `room-overlay.html` + `room-overlay.js`, served by the signage server
and rendered by the player in an iframe. The script reads the status from the URL
query string (the server CSP forbids inline scripts, so the logic lives in the
external `.js`).
## Get an ICS URL
- **Google Calendar:** Calendar settings → *Integrate calendar* → **Secret address in
iCal format**. (Treat it like a password.) For a room, use the room/resource calendar.
- **Outlook / Microsoft 365:** Calendar → Share → **Publish**, then copy the **ICS** link.
- Any CalDAV/ICS publisher works. The feed must be reachable by the machine running `room.js`.
## Serve the overlay assets
Copy `room-overlay.html` and `room-overlay.js` into the signage server's web root (the
same directory that serves the SPA), so they're reachable at
`https://<your-server>/room-overlay.html`. They must be **same-origin** with the player
(the overlay runs in an iframe under the server's CSP).
## Configure
```bash
cp config.example.json config.json
# edit config.json: api_base, api_token (st_ token with the 'full' scope),
# overlay_base_url, device_id (a device OR a group id), room_name, ics_url
```
## Run
```bash
npm start # or: node room.js config.json
```
Stop with Ctrl-C — it clears the overlay on the way out.
### Local quick-start (self-signed dev server)
For a local ScreenTinker instance on `https://localhost:3443` with a self-signed cert:
```json
{
"room_name": "Aspen Room",
"api_base": "https://localhost:3443/",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://localhost:3443/room-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"ics_url": "https://calendar.google.com/calendar/ical/.../basic.ics",
"poll_interval_sec": 60
}
```
```bash
NODE_TLS_REJECT_UNAUTHORIZED=0 node room.js config.json
```
(`NODE_TLS_REJECT_UNAUTHORIZED=0` only to accept the dev cert — never in production.)
Remember to copy `room-overlay.html` + `room-overlay.js` into the server's web root first.
## Offline demo / test
`test.js` runs the ICS parser and status logic against `fixture-room.ics` at a fixed
clock — no server, no network:
```bash
npm test
```
You can also drive the overlay against the fixture by setting `ics_file` (instead of
`ics_url`) in `config.json`.
## Config reference
| key | meaning |
| --- | --- |
| `room_name` | label shown on the overlay |
| `api_base` | ScreenTinker server base URL |
| `api_token` | `st_` API token with the **full** scope |
| `overlay_base_url` | URL where `room-overlay.html` is served (same-origin with the player) |
| `device_id` | target device **or** group id |
| `ics_url` | calendar feed URL (or use `ics_file` for a local file) |
| `poll_interval_sec` | refresh cadence (default 120) |
| `colors.available` / `colors.busy` | band colors, 6-hex no `#` |
| `overlay.position` | `center` (default), `top-right`, `top-left`, `bottom-right`, `bottom-left` |
| `overlay.width` / `overlay.height` / `overlay.border_radius` | overlay box geometry |
## Time-zone note
DTSTART/DTEND in UTC (`…Z`) are handled exactly. A *floating* time (no `Z`) is read as
the **local time of the machine running `room.js`**, and `TZID` parameters are not
resolved to their zone. For a single room whose host shares the room's timezone this is
correct; for cross-timezone calendars, publish the feed in UTC.

View file

@ -0,0 +1,17 @@
{
"room_name": "Aspen Room",
"poll_interval_sec": 120,
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/room-overlay.html",
"device_id": "DEVICE_OR_GROUP_ID",
"ics_url": "https://calendar.google.com/calendar/ical/your-room%40group.calendar.google.com/private-xxxxxxxx/basic.ics",
"colors": { "available": "1f9d55", "busy": "CC0000" },
"overlay": { "position": "center", "width": 900, "height": 360, "border_radius": 16 },
"_offline_demo": "to test against the bundled fixture instead of a live calendar, drop ics_url and add:",
"ics_file": null
}

View file

@ -0,0 +1,30 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//ScreenTinker//Room Status Example//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:standup-0618@example.com
DTSTART:20260618T093000Z
DTEND:20260618T094500Z
SUMMARY:Daily Standup
END:VEVENT
BEGIN:VEVENT
UID:sprint-0618@example.com
DTSTART:20260618T140000Z
DTEND:20260618T150000Z
SUMMARY:Sprint Planning
END:VEVENT
BEGIN:VEVENT
UID:oneonone-0618@example.com
DTSTART:20260618T160000Z
DTEND:20260618T163000Z
SUMMARY:1:1 with Da
na
END:VEVENT
BEGIN:VEVENT
UID:retro-0618@example.com
DTSTART:20260618T170000Z
DTEND:20260618T180000Z
SUMMARY:Quarterly Retro\, room A
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,12 @@
{
"name": "pip-room-status-calendar",
"version": "0.1.0",
"description": "Example: turn a ScreenTinker display into a meeting-room Available/Busy sign driven by an ICS calendar feed, via the PiP API.",
"type": "commonjs",
"main": "room.js",
"scripts": {
"start": "node room.js",
"test": "node test.js"
},
"engines": { "node": ">=18" }
}

View file

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Room Status</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
.band { padding: 18px 26px; display: flex; align-items: center; gap: 16px; font-weight: 800;
letter-spacing: .05em; text-transform: uppercase; font-size: clamp(28px, 7vw, 64px); }
.band .dot { width: 18px; height: 18px; border-radius: 50%; background: rgba(255,255,255,.95);
box-shadow: 0 0 14px rgba(255,255,255,.7); }
.body { padding: 20px 26px; display: flex; flex-direction: column; gap: 12px; flex: 1; }
.room { font-size: clamp(18px, 3.4vw, 28px); font-weight: 600; color: #e8e8e8; }
.detail { font-size: clamp(20px, 4.4vw, 34px); font-weight: 700; line-height: 1.15; }
.sub { margin-top: auto; font-size: clamp(15px, 3vw, 22px); color: #b9b9b9; }
</style>
</head>
<body>
<div class="card">
<div class="band" id="band"><span class="dot"></span><span id="state"></span></div>
<div class="body">
<div class="room" id="room"></div>
<div class="detail" id="detail"></div>
<div class="sub" id="sub"></div>
</div>
</div>
<script src="room-overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,13 @@
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
// Reads the room status from the URL query string and paints the card.
(function () {
var q = new URLSearchParams(location.search);
var get = function (k) { return (q.get(k) || '').trim(); };
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || '1f9d55');
document.getElementById('band').style.background = color;
document.getElementById('state').textContent = (get('state') || '—').toUpperCase();
document.getElementById('room').textContent = get('room') || '';
document.getElementById('detail').textContent = get('detail') || '';
document.getElementById('sub').textContent = get('sub') || '';
})();

View file

@ -0,0 +1,255 @@
'use strict';
// Meeting-room "Available / Busy" sign for ScreenTinker, driven by an ICS calendar
// feed. Polls the calendar and pushes a PiP web overlay showing whether the room is
// free right now (green) or in a meeting (red), plus the next/current meeting time.
//
// node room.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
//
// ICS time handling: DTSTART/DTEND ending in "Z" are UTC; a bare date-time
// (YYYYMMDDTHHMMSS) is treated as the monitor host's LOCAL time; an all-day
// VALUE=DATE (YYYYMMDD) spans local midnight..midnight. TZID parameters are NOT
// resolved to their zone — a floating time is read as local. For a single room
// display whose host shares the room's timezone this is correct; cross-timezone
// calendars should publish UTC.
const fs = require('fs');
const path = require('path');
// ---------------------------------------------------------------------------
// ICS parsing (minimal, dependency-free) — pure, exported, offline-testable
// ---------------------------------------------------------------------------
// RFC 5545 line folding: a CRLF followed by a space or tab continues the prior
// line. Unfold first, then split into logical lines.
function unfold(ics) {
return ics.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n[ \t]/g, '');
}
// Parse an ICS date/-time value into epoch ms. Handles:
// 20260618T143000Z -> UTC
// 20260618T143000 -> local (floating)
// 20260618 -> all-day, local midnight
function parseIcsDate(val) {
if (!val) return NaN;
const v = val.trim();
let m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
if (m) {
const [, y, mo, d, h, mi, s, z] = m;
if (z) return Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
return new Date(+y, +mo - 1, +d, +h, +mi, +s).getTime();
}
m = v.match(/^(\d{4})(\d{2})(\d{2})$/);
if (m) {
const [, y, mo, d] = m;
return new Date(+y, +mo - 1, +d, 0, 0, 0).getTime(); // local midnight
}
const t = Date.parse(v);
return Number.isFinite(t) ? t : NaN;
}
// Split a "NAME;PARAM=x:VALUE" property line into { name, value }.
function splitProp(line) {
const idx = line.indexOf(':');
if (idx < 0) return null;
const head = line.slice(0, idx);
const value = line.slice(idx + 1);
const name = head.split(';')[0].toUpperCase();
return { name, value };
}
// RFC 5545 TEXT unescaping (\n \, \; \\).
function unescapeText(s) {
return String(s)
.replace(/\\n/gi, ' ')
.replace(/\\,/g, ',')
.replace(/\\;/g, ';')
.replace(/\\\\/g, '\\');
}
// Extract VEVENTs as { summary, start, end } (start/end = epoch ms). Events
// without a parseable start are skipped; a missing end defaults to start (a
// zero-length event, which is never "current").
function parseIcs(ics) {
const lines = unfold(ics).split('\n');
const events = [];
let cur = null;
for (const raw of lines) {
const line = raw.trim();
if (line === 'BEGIN:VEVENT') { cur = {}; continue; }
if (line === 'END:VEVENT') {
if (cur && Number.isFinite(cur.start)) {
events.push({
summary: cur.summary || '(busy)',
start: cur.start,
end: Number.isFinite(cur.end) ? cur.end : cur.start,
});
}
cur = null;
continue;
}
if (!cur) continue;
const p = splitProp(line);
if (!p) continue;
if (p.name === 'DTSTART') cur.start = parseIcsDate(p.value);
else if (p.name === 'DTEND') cur.end = parseIcsDate(p.value);
else if (p.name === 'SUMMARY') cur.summary = unescapeText(p.value);
}
return events;
}
// Given events and a `now` (epoch ms), decide if the room is busy. "current" is
// the soonest-ending event covering now; "next" is the soonest event starting
// strictly after now.
function status(events, now) {
const current = events
.filter(e => e.start <= now && now < e.end)
.sort((a, b) => a.end - b.end)[0] || null;
const next = events
.filter(e => e.start > now)
.sort((a, b) => a.start - b.start)[0] || null;
const trim = e => e && { summary: e.summary, start: e.start, end: e.end };
return {
state: current ? 'busy' : 'available',
current: trim(current),
next: trim(next),
busyUntil: current ? current.end : null,
freeUntil: !current && next ? next.start : null,
};
}
module.exports = { parseIcs, parseIcsDate, status, unfold, unescapeText };
// ---------------------------------------------------------------------------
// Runtime (only when executed directly) — config load, PiP push, poll loop
// ---------------------------------------------------------------------------
function runMain() {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const POLL_SEC = cfg.poll_interval_sec || 120;
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE_ID = cfg.device_id;
const ROOM_NAME = cfg.room_name || 'Meeting Room';
const OVERLAY = cfg.overlay || {};
const COLORS = Object.assign({ available: '1f9d55', busy: 'CC0000' }, cfg.colors || {});
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE_ID || (!cfg.ics_url && !cfg.ics_file)) {
console.error('config must set api_base, api_token, overlay_base_url, device_id, and ics_url or ics_file.');
process.exit(1);
}
const hhmm = ms => {
const d = new Date(ms);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// Map a status result to the overlay query fields.
function viewOf(st) {
if (st.state === 'busy') {
return {
state: 'BUSY', color: COLORS.busy,
detail: st.current ? st.current.summary : 'In a meeting',
sub: st.busyUntil ? `until ${hhmm(st.busyUntil)}` : '',
};
}
return {
state: 'AVAILABLE', color: COLORS.available,
detail: st.next ? `Next: ${st.next.summary}` : 'No more meetings today',
sub: st.next ? `at ${hhmm(st.next.start)}` : '',
};
}
function overlayUri(st) {
const v = viewOf(st);
const q = new URLSearchParams({
state: v.state, room: ROOM_NAME, detail: v.detail || '', sub: v.sub || '',
color: (v.color || '1f9d55').replace(/[^0-9a-fA-F]/g, ''),
});
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
}
let activePip = null;
async function pipShow(st) {
const body = {
device_id: DEVICE_ID, type: 'web', uri: overlayUri(st),
position: OVERLAY.position || 'center',
width: OVERLAY.width || 900, height: OVERLAY.height || 360,
duration: 0, // persistent; we refresh each poll and clear on exit
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
close_button: false,
title: ROOM_NAME,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear(pipId) {
const res = await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE_ID, pip_id: pipId || undefined }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
}
}
async function loadIcs() {
if (cfg.ics_file) return fs.readFileSync(cfg.ics_file, 'utf8');
const res = await fetch(cfg.ics_url, { headers: { Accept: 'text/calendar' } });
if (!res.ok) throw new Error(`ICS HTTP ${res.status}`);
return res.text();
}
async function tick() {
let events;
try { events = parseIcs(await loadIcs()); }
catch (e) { console.error(`[${new Date().toISOString()}] calendar load error: ${e.message}`); return; }
const st = status(events, Date.now());
const v = viewOf(st);
try {
// last-show-wins: re-pushing replaces the previous overlay with fresh state.
const pipId = await pipShow(st);
activePip = pipId;
console.log(`[${new Date().toISOString()}] ${v.state}${v.detail} ${v.sub} (pip=${pipId})`);
} catch (e) {
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
}
}
(async () => {
console.log(`Room status sign starting — room="${ROOM_NAME}"`);
console.log(` source: ${cfg.ics_file ? `file ${cfg.ics_file}` : cfg.ics_url}`);
console.log(` poll: every ${POLL_SEC}s`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
console.log('\nclearing overlay before exit...');
try { if (activePip) await pipClear(activePip); } catch { /* best effort */ }
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
})();
}
if (require.main === module) runMain();

View file

@ -0,0 +1,45 @@
'use strict';
// Offline test for the ICS parser + room status logic. No network, fixed clock.
const fs = require('fs');
const { parseIcs, status } = require('./room');
const events = parseIcs(fs.readFileSync('./fixture-room.ics', 'utf8'));
console.log(`Parsed ${events.length} event(s):\n`);
for (const e of events) {
console.log(`${e.summary} ${new Date(e.start).toISOString()} -> ${new Date(e.end).toISOString()}`);
}
console.log('');
// 14:30Z is inside Sprint Planning (14:00-15:00); next is the 1:1 at 16:00.
const nowBusy = Date.UTC(2026, 5, 18, 14, 30, 0);
const sBusy = status(events, nowBusy);
// 15:30Z is between meetings; room free, next is the 1:1 at 16:00.
const nowFree = Date.UTC(2026, 5, 18, 15, 30, 0);
const sFree = status(events, nowFree);
const fold = events.find(e => e.summary === '1:1 with Dana'); // proves line-unfolding
const esc = events.find(e => e.summary === 'Quarterly Retro, room A'); // proves TEXT unescaping
const ok =
events.length === 4 &&
sBusy.state === 'busy' &&
sBusy.current && sBusy.current.summary === 'Sprint Planning' &&
sBusy.busyUntil === Date.UTC(2026, 5, 18, 15, 0, 0) &&
sBusy.next && sBusy.next.summary === '1:1 with Dana' &&
sFree.state === 'available' &&
sFree.current === null &&
sFree.next && sFree.next.summary === '1:1 with Dana' &&
sFree.freeUntil === Date.UTC(2026, 5, 18, 16, 0, 0) &&
!!fold && !!esc;
console.log('--- assertions ---');
console.log('at 14:30Z =>', sBusy.state, '|', sBusy.current && sBusy.current.summary, '| next:', sBusy.next && sBusy.next.summary);
console.log('at 15:30Z =>', sFree.state, '| next:', sFree.next && sFree.next.summary);
console.log('folded summary parsed:', !!fold, '("1:1 with Dana")');
console.log('escaped summary parsed:', !!esc, '("Quarterly Retro, room A")');
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

View file

@ -0,0 +1,4 @@
config.json
node_modules/
package-lock.json
demo-noaa.json

View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emergency Alert</title>
<style>
html, body { margin: 0; height: 100%; background: transparent; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(18px, 4vw, 30px); }
.band .pulse { width: 16px; height: 16px; border-radius: 50%; background: rgba(255,255,255,.95);
animation: pulse 1.1s ease-in-out infinite; }
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
.body { padding: 18px 24px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
.headline { font-size: clamp(20px, 5vw, 38px); font-weight: 700; line-height: 1.15; }
.meta { font-size: clamp(13px, 2.6vw, 18px); color: #cfcfcf; display: flex; flex-wrap: wrap; gap: 6px 18px; }
.meta b { color: #fff; font-weight: 600; }
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
.agency { opacity: .8; }
</style>
</head>
<body>
<div class="card">
<div class="band" id="band"><span class="pulse"></span><span id="level">ALERT</span></div>
<div class="body">
<div class="headline" id="headline"></div>
<div class="meta" id="meta"></div>
<div class="footer"><span class="agency" id="agency"></span> <span id="updated"></span></div>
</div>
</div>
<script src="overlay.js"></script>
</body>
</html>

View file

@ -0,0 +1,183 @@
'use strict';
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
// wrap their alerts the same way). Three jobs:
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
// flip in one place so callers never have to think about it.
const { XMLParser } = require('fast-xml-parser');
const parser = new XMLParser({
ignoreAttributes: false,
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
parseTagValue: false, // keep everything as strings; we coerce deliberately
trimValues: true,
});
// Always work with arrays even when the XML has a single child.
function arr(x) {
if (x === undefined || x === null) return [];
return Array.isArray(x) ? x : [x];
}
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
// these instead of regexing the HTML-encoded <description> blob.
function paramsToMap(info) {
const out = {};
for (const p of arr(info && info.parameter)) {
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
}
return out;
}
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
function parsePolygon(str) {
if (!str) return null;
const pts = String(str).trim().split(/\s+/).map((pair) => {
const [lat, lon] = pair.split(',').map(Number);
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
}).filter(Boolean);
return pts.length >= 3 ? pts : null;
}
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
// which can never contain anything, so callers should treat a 0-radius circle as "no
// usable circle" and rely on the polygon.
function parseCircle(str) {
if (!str) return null;
const [center, radius] = String(str).trim().split(/\s+/);
const [lat, lon] = (center || '').split(',').map(Number);
const km = Number(radius);
if (![lat, lon, km].every(Number.isFinite)) return null;
return { lat, lon, km };
}
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
function pointInPolygon(pt, poly) {
const x = pt.lon, y = pt.lat;
let inside = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].lon, yi = poly[i].lat;
const xj = poly[j].lon, yj = poly[j].lat;
const intersect = (yi > y) !== (yj > y) &&
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
function haversineKm(a, b) {
const R = 6371;
const toRad = (d) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
// circle. Returns false when the alert has no usable geometry.
function pointInAlertArea(point, alert) {
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
return false;
}
// Flatten one embedded CAP <alert> into the shape the monitor works with.
function normaliseAlert(a) {
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
const params = paramsToMap(info);
return {
identifier: a.identifier != null ? String(a.identifier) : null,
msgType: a.msgType || null, // Alert | Update | Cancel
sent: a.sent || null,
headline: info.headline || params.IncidentName || '(no headline)',
event: info.event || null,
category: info.category || null,
responseType: info.responseType || null, // mostly "Monitor" in this feed
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
expires: info.expires || null,
web: info.web || null,
// RFS-specific, the field that actually carries urgency:
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
incidentType: params.IncidentType || null,
status: params.Status || null,
size: params.Fireground || params.Size || null,
council: params.CouncilArea || params.Location || null,
isFire: (params.IsFire || '').toLowerCase() === 'yes',
polygon: parsePolygon(area.polygon),
circle: parseCircle(area.circle),
areaDesc: area.areaDesc || null,
params,
};
}
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
function parseFeed(xml) {
const root = parser.parse(xml);
const dist = root.EDXLDistribution || root.Distribution || null;
const alerts = [];
if (dist) {
for (const co of arr(dist.contentObject)) {
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
for (const e of arr(embedded)) {
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
}
}
} else {
// Fallback: a bare CAP feed (no EDXL envelope).
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
}
return alerts;
}
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
function isExpired(alert, now = Date.now()) {
if (!alert.expires) return false;
const t = Date.parse(alert.expires);
return Number.isFinite(t) && t <= now;
}
// The gate: should this alert put something on a screen at `point`?
// - msgType must be Alert/Update (Cancel clears, never shows)
// - not expired
// - AlertLevel is at or above the configured threshold
// - the screen falls inside the alert area
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
function shouldShow(alert, point, opts = {}) {
const levels = opts.alertLevels || DEFAULT_LEVELS;
const now = opts.now || Date.now();
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
}
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
return { show: false, reason: 'no usable geometry' };
}
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
return { show: true, reason: 'in-area, at/above threshold' };
}
module.exports = {
parseFeed,
normaliseAlert,
parsePolygon,
parseCircle,
pointInPolygon,
pointInAlertArea,
haversineKm,
isExpired,
shouldShow,
DEFAULT_LEVELS,
};

View file

@ -0,0 +1,21 @@
{
"source": "noaa",
"poll_interval_sec": 60,
"api_base": "https://signage.example.com",
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
"overlay_base_url": "https://signage.example.com/alert-overlay.html",
"min_severity": "Severe",
"urgencies": null,
"noaa_user_agent": "ScreenTinker-CAP-Alert-Monitor (you@example.com)",
"screens": [
{ "name": "OKC lobby", "lat": 35.4676, "lon": -97.5164, "device_id": "DEVICE_OR_GROUP_ID" }
],
"overlay": { "position": "center", "width": 900, "height": 320, "border_radius": 16 },
"_demo": "to watch show->expire-removal deterministically: run `node make-demo-alert.js 90`, then add the next line:",
"test_feed_file": null
}

View file

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?><?xml-stylesheet href="lib/RFS_EDXL_simple.xsl" type="text/xsl"?>
<EDXLDistribution xmlns="urn:oasis:names:tc:emergency:EDXL:DE:1.0">
<distributionID>RFSUniqueID:2026-06-18T00:00:00Z</distributionID>
<senderID>webmaster@rfs.nsw.gov.au</senderID>
<dateTimeSent>2026-06-18T10:00:00+10:00</dateTimeSent>
<distributionStatus>Actual</distributionStatus>
<distributionType>Report</distributionType>
<contentObject>
<contentDescription>Information on Aberdeen HR</contentDescription>
<xmlContent><embeddedXMLContent>
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>2026-06-17T14:46:00.0000000:662900</identifier>
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-17T14:46:00+10:00</sent>
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
<info>
<category>Fire</category><event>Fire</event><responseType>Monitor</responseType>
<urgency>Unknown</urgency><severity>Unknown</severity><certainty>Observed</certainty>
<expires>2026-06-30T21:25:21+10:00</expires>
<headline>Aberdeen HR</headline>
<parameter><valueName>AlertLevel</valueName><value>Planned Burn</value></parameter>
<parameter><valueName>IncidentType</valueName><value>Hazard Reduction</value></parameter>
<parameter><valueName>Status</valueName><value>Under control</value></parameter>
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
<area>
<areaDesc>STANBOROUGH</areaDesc>
<polygon>-29.974,151.103 -29.984,151.103 -29.984,151.108 -29.974,151.108 -29.974,151.103</polygon>
<circle>-29.978,151.105 0</circle>
</area>
</info>
</alert>
</embeddedXMLContent></xmlContent>
</contentObject>
<contentObject>
<contentDescription>Emergency Warning - Test Ridge</contentDescription>
<xmlContent><embeddedXMLContent>
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>2026-06-18T09:30:00.0000000:670001</identifier>
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:30:00+10:00</sent>
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
<info>
<category>Fire</category><event>Bushfire</event><responseType>Evacuate</responseType>
<urgency>Immediate</urgency><severity>Extreme</severity><certainty>Observed</certainty>
<expires>2026-06-30T21:00:00+10:00</expires>
<headline>Test Ridge Road Fire</headline>
<parameter><valueName>AlertLevel</valueName><value>Emergency Warning</value></parameter>
<parameter><valueName>IncidentType</valueName><value>Bush Fire</value></parameter>
<parameter><valueName>Status</valueName><value>Out of control</value></parameter>
<parameter><valueName>CouncilArea</valueName><value>Testshire</value></parameter>
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
<area>
<areaDesc>Test Ridge - 5km around the screen</areaDesc>
<polygon>-33.90,151.10 -33.90,151.30 -33.80,151.30 -33.80,151.10 -33.90,151.10</polygon>
<circle>-33.85,151.20 8</circle>
</area>
</info>
</alert>
</embeddedXMLContent></xmlContent>
</contentObject>
<contentObject>
<contentDescription>Watch and Act - far away</contentDescription>
<xmlContent><embeddedXMLContent>
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
<identifier>2026-06-18T09:45:00.0000000:670002</identifier>
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:45:00+10:00</sent>
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
<info>
<category>Fire</category><event>Bushfire</event><responseType>Prepare</responseType>
<urgency>Expected</urgency><severity>Severe</severity><certainty>Likely</certainty>
<expires>2026-06-30T21:00:00+10:00</expires>
<headline>Distant Valley Fire</headline>
<parameter><valueName>AlertLevel</valueName><value>Watch and Act</value></parameter>
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
<area>
<areaDesc>Distant Valley (far from screen)</areaDesc>
<polygon>-31.00,150.00 -31.00,150.10 -30.90,150.10 -30.90,150.00 -31.00,150.00</polygon>
<circle>-30.95,150.05 0</circle>
</area>
</info>
</alert>
</embeddedXMLContent></xmlContent>
</contentObject>
</EDXLDistribution>

View file

@ -0,0 +1,90 @@
{
"type": "FeatureCollection",
"title": "Fixture: NWS active alerts (offline test for noaa-parse)",
"features": [
{
"id": "NWS-TEST-TORNADO-1",
"type": "Feature",
"geometry": null,
"properties": {
"id": "NWS-TEST-TORNADO-1",
"event": "Tornado Warning",
"severity": "Extreme",
"urgency": "Immediate",
"certainty": "Observed",
"messageType": "Alert",
"status": "Actual",
"sent": "2026-06-18T10:00:00-05:00",
"effective": "2026-06-18T10:00:00-05:00",
"expires": "2026-06-18T10:01:00-05:00",
"headline": "Tornado Warning issued June 18 at 10:00AM CDT",
"areaDesc": "Test County, ST",
"senderName": "NWS Test Office",
"response": "Shelter"
}
},
{
"id": "NWS-TEST-WINTER-3",
"type": "Feature",
"geometry": null,
"properties": {
"id": "NWS-TEST-WINTER-3",
"event": "Winter Storm Warning",
"severity": "Severe",
"urgency": "Expected",
"certainty": "Likely",
"messageType": "Alert",
"status": "Actual",
"sent": "2026-06-18T09:30:00-05:00",
"effective": "2026-06-18T09:30:00-05:00",
"expires": "2026-06-18T20:00:00-05:00",
"headline": "Winter Storm Warning in effect",
"areaDesc": "Test County, ST",
"senderName": "NWS Test Office",
"response": "Prepare"
}
},
{
"id": "NWS-TEST-FLOOD-2",
"type": "Feature",
"geometry": null,
"properties": {
"id": "NWS-TEST-FLOOD-2",
"event": "Flood Advisory",
"severity": "Minor",
"urgency": "Expected",
"certainty": "Likely",
"messageType": "Alert",
"status": "Actual",
"sent": "2026-06-18T09:45:00-05:00",
"effective": "2026-06-18T09:45:00-05:00",
"expires": "2026-06-18T20:00:00-05:00",
"headline": "Flood Advisory in effect",
"areaDesc": "Test County, ST",
"senderName": "NWS Test Office",
"response": "Avoid"
}
},
{
"id": "NWS-TEST-CANCEL-4",
"type": "Feature",
"geometry": null,
"properties": {
"id": "NWS-TEST-CANCEL-4",
"event": "Severe Thunderstorm Warning",
"severity": "Severe",
"urgency": "Immediate",
"certainty": "Observed",
"messageType": "Cancel",
"status": "Actual",
"sent": "2026-06-18T09:55:00-05:00",
"effective": "2026-06-18T09:55:00-05:00",
"expires": "2026-06-18T20:00:00-05:00",
"headline": "Severe Thunderstorm Warning cancelled",
"areaDesc": "Test County, ST",
"senderName": "NWS Test Office",
"response": "AllClear"
}
}
]
}

View file

@ -0,0 +1,25 @@
// Usage: node make-demo-alert.js [seconds] [outfile]
// Writes a NWS-shaped FeatureCollection with one Extreme alert expiring `seconds` from now
// (default 90). Point the monitor's config.test_feed_file at the output to watch show->expire.
const fs = require('fs');
const secs = parseInt(process.argv[2] || '90', 10);
const out = process.argv[3] || 'demo-noaa.json';
const now = new Date();
const expires = new Date(now.getTime() + secs * 1000);
const fc = {
type: 'FeatureCollection',
features: [{
id: 'https://api.weather.gov/alerts/DEMO-EXPIRY-1', type: 'Feature', geometry: null,
properties: {
id: 'DEMO-EXPIRY-1', areaDesc: 'Demo County',
sent: now.toISOString(), effective: now.toISOString(), onset: now.toISOString(),
expires: expires.toISOString(), ends: expires.toISOString(),
status: 'Actual', messageType: 'Alert', category: 'Met',
severity: 'Extreme', certainty: 'Observed', urgency: 'Immediate',
event: 'Tornado Warning', senderName: 'NWS Demo Office',
headline: `DEMO alert — auto-clears at ${expires.toLocaleTimeString()}`, response: 'Shelter',
},
}],
};
fs.writeFileSync(out, JSON.stringify(fc, null, 2));
console.log(`wrote ${out}: DEMO Tornado Warning expiring in ${secs}s (at ${expires.toISOString()})`);

View file

@ -0,0 +1,247 @@
'use strict';
// CAP -> ScreenTinker PiP monitor. Supports two sources via config.source:
// "capau" (default) - NSW RFS EDXL/CAP-AU feed, client-side polygon geofence, gate on AlertLevel.
// "noaa" - api.weather.gov, server-side ?point= geofence, gate on real CAP severity.
//
// For each configured screen it pushes a PiP web overlay when a qualifying alert covers
// that screen, and clears it when the alert expires, is cancelled, or drops out. Overlays
// also self-remove at the alert's `expires` time via the PiP `duration` field (the player
// auto-clears), so they vanish on expiry even between polls.
//
// node monitor.js [path/to/config.json]
//
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
const cap = require('./cap-parse');
const noaa = require('./noaa-parse');
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const SOURCE = (cfg.source || 'capau').toLowerCase();
const POLL_SEC = cfg.poll_interval_sec || 120;
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const SCREENS = cfg.screens || [];
const OVERLAY = cfg.overlay || {};
const PIP_DUR_MAX = 86400; // PiP API cap (seconds)
// capau-only:
const FEED_URL = cfg.feed_url || 'https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml';
const ALERT_LEVELS = cfg.alert_levels || cap.DEFAULT_LEVELS;
const CAPAU_COLORS = Object.assign({ 'Emergency Warning': 'CC0000', 'Watch and Act': 'E8730C', 'Advice': 'F2C200' }, OVERLAY.colors || {});
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || SCREENS.length === 0) {
console.error('config must set api_base, api_token, overlay_base_url, and at least one screen.');
process.exit(1);
}
// active overlays: key `${device_id}|${identifier}` -> { pip_id, expiresAt }
const active = new Map();
const keyFor = (deviceId, identifier) => `${deviceId}|${identifier}`;
// Map a normalised alert (either source) to the overlay's display fields.
function viewOf(alert) {
if (alert.source === 'noaa') {
return {
level: alert.displayLevel, color: alert.color, headline: alert.headline,
area: alert.areaDesc || '', status: alert.response || alert.urgency || '',
updated: alert.sent || '', agency: alert.agency || 'US National Weather Service',
};
}
return {
level: alert.alertLevel || 'Alert',
color: CAPAU_COLORS[alert.alertLevel] || 'CC0000',
headline: alert.headline || '',
area: alert.areaDesc || alert.council || '',
status: alert.status || '',
updated: alert.sent || '',
agency: OVERLAY.agency || 'NSW Rural Fire Service',
};
}
function overlayUri(alert) {
const v = viewOf(alert);
const q = new URLSearchParams({
level: v.level || '', headline: v.headline || '', area: v.area || '',
status: v.status || '', updated: v.updated || '',
color: (v.color || 'CC0000').replace(/[^0-9a-fA-F]/g, ''), agency: v.agency || '',
});
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
}
// Seconds until expiry, clamped to the PiP duration range. 0 => keep until we clear it.
function durationForExpiry(alert, now = Date.now()) {
if (!alert.expires) return 0;
const t = Date.parse(alert.expires);
if (!Number.isFinite(t)) return 0;
const secs = Math.floor((t - now) / 1000);
if (secs <= 0) return 0;
return Math.min(secs, PIP_DUR_MAX);
}
async function pipShow(deviceId, alert) {
const body = {
device_id: deviceId, type: 'web', uri: overlayUri(alert),
position: OVERLAY.position || 'center',
width: OVERLAY.width || 900, height: OVERLAY.height || 320,
duration: durationForExpiry(alert),
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
close_button: false,
title: viewOf(alert).level,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return { pipId: json.pip_id, duration: body.duration };
}
async function pipClear(deviceId, pipId) {
const res = await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: deviceId, pip_id: pipId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
}
}
// Gate without geofence (for the test_feed_file override, where geometry/point isn't real).
function passesNonGeo(alert, now) {
if (alert.msgType === 'Cancel') return false;
if (SOURCE === 'noaa') {
if (alert.status && alert.status !== 'Actual') return false;
if (noaa.isExpired(alert, now)) return false;
return (noaa.SEV_RANK[alert.severity] || 0) >= (noaa.SEV_RANK[cfg.min_severity || 'Severe'] || 0);
}
if (cap.isExpired(alert, now)) return false;
return !!alert.alertLevel && ALERT_LEVELS.includes(alert.alertLevel);
}
async function collect(now) {
const pairs = [];
const polled = new Set();
// Test/demo override: read alerts from a local file instead of the network, geofence
// bypassed (every alert applies to every screen). Lets you watch the show->expire->remove
// lifecycle on a deterministic timer. Remove `test_feed_file` from config for real use.
if (cfg.test_feed_file) {
let alerts = [];
try {
const raw = fs.readFileSync(cfg.test_feed_file, 'utf8');
alerts = SOURCE === 'noaa' ? noaa.normaliseFeatureCollection(raw) : cap.parseFeed(raw);
} catch (e) { console.error(`test_feed_file read error: ${e.message}`); return { pairs, polled }; }
for (const screen of SCREENS) {
polled.add(screen.device_id);
for (const a of alerts) {
if (a.identifier && passesNonGeo(a, now)) pairs.push({ screen, alert: a });
}
}
return { pairs, polled };
}
if (SOURCE === 'noaa') {
for (const screen of SCREENS) {
let alerts;
try { alerts = await noaa.fetchActiveForPoint(screen.lat, screen.lon, cfg.noaa_user_agent); }
catch (e) { console.error(`[${new Date().toISOString()}] NWS fetch error for ${screen.name}: ${e.message}`); continue; }
polled.add(screen.device_id);
for (const a of alerts) {
if (!a.identifier) continue;
if (noaa.shouldShow(a, { minSeverity: cfg.min_severity, urgencies: cfg.urgencies, now }).show) {
pairs.push({ screen, alert: a });
}
}
}
} else {
let alerts;
try {
const res = await fetch(FEED_URL, { headers: { Accept: 'application/xml, text/xml' } });
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
alerts = cap.parseFeed(await res.text());
} catch (e) {
console.error(`[${new Date().toISOString()}] feed fetch/parse error: ${e.message}`);
return { pairs: [], polled };
}
for (const screen of SCREENS) {
polled.add(screen.device_id);
const point = { lat: screen.lat, lon: screen.lon };
for (const a of alerts) {
if (!a.identifier) continue;
if (cap.shouldShow(a, { alertLevels: ALERT_LEVELS, now }).show) pairs.push({ screen, alert: a });
}
}
}
return { pairs, polled };
}
async function tick() {
const now = Date.now();
const { pairs, polled } = await collect(now);
const stillQualifying = new Set();
for (const { screen, alert } of pairs) {
const key = keyFor(screen.device_id, alert.identifier);
stillQualifying.add(key);
if (active.has(key)) continue;
try {
const { pipId, duration } = await pipShow(screen.device_id, alert);
active.set(key, { pip_id: pipId, expiresAt: Date.parse(alert.expires) || null });
const v = viewOf(alert);
console.log(`[${new Date().toISOString()}] SHOW "${alert.headline}" (${v.level}) on ${screen.name} pip=${pipId} dur=${duration || '∞'}s`);
} catch (e) {
console.error(`[${new Date().toISOString()}] show error on ${screen.name}: ${e.message}`);
}
}
for (const [key, rec] of [...active.entries()]) {
const [deviceId] = key.split('|');
if (!polled.has(deviceId)) continue;
if (stillQualifying.has(key)) continue;
try {
await pipClear(deviceId, rec.pip_id);
active.delete(key);
console.log(`[${new Date().toISOString()}] CLEAR pip=${rec.pip_id} on ${deviceId} (gone/expired/cancelled)`);
} catch (e) {
console.error(`[${new Date().toISOString()}] clear error: ${e.message}`);
}
}
}
async function main() {
console.log(`CAP PiP monitor starting — source=${SOURCE}`);
console.log(` poll: every ${POLL_SEC}s`);
if (SOURCE === 'noaa') console.log(` min severity: ${cfg.min_severity || 'Severe'}${cfg.urgencies ? `, urgency in [${cfg.urgencies.join(',')}]` : ''}`);
else console.log(` feed: ${FEED_URL}\n levels: ${ALERT_LEVELS.join(', ')}`);
console.log(` screens: ${SCREENS.map(s => `${s.name}(${s.lat},${s.lon})`).join(', ')}`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
console.log('\nclearing active overlays before exit...');
for (const [key, rec] of active.entries()) {
const [deviceId] = key.split('|');
try { await pipClear(deviceId, rec.pip_id); } catch { /* best effort */ }
}
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();

Some files were not shown because too many files have changed in this diff Show more