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>
This commit is contained in:
screentinker 2026-06-18 20:20:37 -05:00 committed by GitHub
parent 5f83fc20d3
commit 0b138f10c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
118 changed files with 6975 additions and 0 deletions

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();

View file

@ -0,0 +1,103 @@
'use strict';
// NOAA / US National Weather Service adapter for api.weather.gov.
//
// Unlike the RFS CAP-AU feed (EDXL-wrapped XML, geofence client-side, gate on a custom
// AlertLevel parameter because CAP severity is "Unknown"), NWS is:
// - JSON (GeoJSON FeatureCollection), parsed directly.
// - Geofenced BY THE API: /alerts/active?point=lat,lon returns only alerts covering
// that point, so there's no polygon math here.
// - Gated on the REAL CAP severity/urgency, which NWS actually populates.
// - api.weather.gov REQUIRES a User-Agent header (403 without one).
//
// Exposes a pure normaliser/gate (offline-testable) and a thin live fetch.
// Severity ranking for threshold comparison.
const SEV_RANK = { Extreme: 4, Severe: 3, Moderate: 2, Minor: 1, Unknown: 0 };
// Default colours by severity (overridable via cfg.colors).
const SEV_COLORS = { Extreme: '7B0000', Severe: 'CC0000', Moderate: 'E8730C', Minor: 'F2C200', Unknown: '888888' };
// Normalise one GeoJSON feature's `properties` into the shared alert shape the monitor
// and overlay use (same field names the CAP-AU path produces, so the rest is source-agnostic).
function normaliseFeature(feature) {
const p = (feature && feature.properties) || {};
const severity = p.severity || 'Unknown';
return {
source: 'noaa',
identifier: p.id || (feature && feature.id) || null,
msgType: p.messageType || null, // Alert | Update | Cancel
status: p.status || null, // Actual | Exercise | Test | ...
sent: p.sent || null,
expires: p.expires || p.ends || null, // NWS populates expires reliably; ends as fallback
headline: p.headline || p.event || '(no headline)',
event: p.event || null,
severity,
urgency: p.urgency || null, // Immediate | Expected | Future | Past | Unknown
certainty: p.certainty || null,
response: p.response || null, // Shelter | Evacuate | Prepare | Avoid | Monitor | ...
areaDesc: p.areaDesc || null,
agency: p.senderName || 'US National Weather Service',
web: (p.parameters && p.parameters.WMOidentifier) ? null : null, // NWS has no single web link field
// for overlay display:
displayLevel: p.event || severity, // the event name reads better than the bare severity
color: SEV_COLORS[severity] || SEV_COLORS.Unknown,
};
}
function normaliseFeatureCollection(json) {
const obj = typeof json === 'string' ? JSON.parse(json) : json;
const feats = (obj && Array.isArray(obj.features)) ? obj.features : [];
return feats.map(normaliseFeature);
}
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: NWS-style. Show if it's a live Alert/Update, not expired, status Actual, and
// at/above the severity threshold (default Severe+). Optionally also require an urgency in
// cfg.urgencies. Geofencing already happened at fetch time (?point=).
function shouldShow(alert, opts = {}) {
const minSev = opts.minSeverity || 'Severe';
const now = opts.now || Date.now();
const urgencies = opts.urgencies || null; // e.g. ["Immediate","Expected"] or null = any
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
if (alert.status && alert.status !== 'Actual') return { show: false, reason: `status ${alert.status}` };
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
if ((SEV_RANK[alert.severity] || 0) < (SEV_RANK[minSev] || 0)) {
return { show: false, reason: `severity ${alert.severity} below ${minSev}` };
}
if (urgencies && !urgencies.includes(alert.urgency)) {
return { show: false, reason: `urgency ${alert.urgency} not in [${urgencies.join(',')}]` };
}
return { show: true, reason: `${alert.severity}, at/above ${minSev}` };
}
// Live fetch: alerts active at a point. NWS resolves the point to its zones server-side, so
// everything returned already covers the screen. Requires a User-Agent.
async function fetchActiveForPoint(lat, lon, userAgent) {
// API caps coordinate precision at 4 decimals.
const p = `${Number(lat).toFixed(4)},${Number(lon).toFixed(4)}`;
const url = `https://api.weather.gov/alerts/active?point=${encodeURIComponent(p)}`;
const res = await fetch(url, {
headers: {
'User-Agent': userAgent || 'ScreenTinker-CAP-Alert-Monitor (set contact in config)',
Accept: 'application/geo+json',
},
});
if (!res.ok) throw new Error(`NWS HTTP ${res.status}`);
return normaliseFeatureCollection(await res.text());
}
module.exports = {
normaliseFeature,
normaliseFeatureCollection,
shouldShow,
isExpired,
fetchActiveForPoint,
SEV_RANK,
SEV_COLORS,
};

View file

@ -0,0 +1,29 @@
// 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';
document.getElementById('agency').textContent = get('agency') || '';
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());
}
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-noaa",
"version": "0.1.0",
"description": "Example: monitor a CAP feed (NSW RFS CAP-AU or US NWS / api.weather.gov) 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 && node test-noaa.js"
},
"engines": { "node": ">=18" },
"dependencies": {
"fast-xml-parser": "^4.5.0"
}
}

View file

@ -0,0 +1,47 @@
const fs = require('fs');
const noaa = require('./noaa-parse');
const alerts = noaa.normaliseFeatureCollection(fs.readFileSync('./fixture-noaa.json', 'utf8'));
const byId = Object.fromEntries(alerts.map(a => [a.identifier, a]));
// "now" = 30s before the tornado's 1-minute expiry, so it's still active.
const now = Date.parse('2026-06-18T10:00:30-05:00');
console.log(`Parsed ${alerts.length} NWS alert(s):\n`);
for (const a of alerts) {
const g = noaa.shouldShow(a, { minSeverity: 'Severe', now });
console.log(`${a.event} severity=${a.severity} urgency=${a.urgency} msgType=${a.msgType}`);
console.log(` expires=${a.expires} => ${g.show ? 'SHOW' : 'skip'} (${g.reason})\n`);
}
// duration the player would receive for the tornado (seconds until expiry, capped)
function durFor(a, t) {
if (!a.expires) return 0;
const e = Date.parse(a.expires); if (!Number.isFinite(e)) return 0;
return Math.max(0, Math.min(Math.floor((e - t) / 1000), 86400));
}
const tornado = byId['NWS-TEST-TORNADO-1'];
const dur = durFor(tornado, now);
// after the tornado expires, it must stop qualifying (self-removal path)
const later = Date.parse('2026-06-18T10:02:00-05:00');
const tornadoAfter = noaa.shouldShow(tornado, { minSeverity: 'Severe', now: later });
const shown = alerts.filter(a => noaa.shouldShow(a, { minSeverity: 'Severe', now }).show).map(a => a.event);
const ok =
shown.length === 2 &&
shown.includes('Tornado Warning') &&
shown.includes('Winter Storm Warning') &&
noaa.shouldShow(byId['NWS-TEST-FLOOD-2'], { minSeverity: 'Severe', now }).reason.includes('below') &&
noaa.shouldShow(byId['NWS-TEST-CANCEL-4'], { minSeverity: 'Severe', now }).reason === 'cancelled' &&
dur === 30 &&
tornadoAfter.show === false && tornadoAfter.reason === 'expired';
console.log('--- assertions ---');
console.log('shows (Severe+):', shown.join(', '));
console.log('Flood Advisory (Minor) filtered:', noaa.shouldShow(byId['NWS-TEST-FLOOD-2'], { minSeverity: 'Severe', now }).reason);
console.log('Cancel filtered:', noaa.shouldShow(byId['NWS-TEST-CANCEL-4'], { minSeverity: 'Severe', now }).reason);
console.log(`tornado overlay duration the player gets: ${dur}s (auto-clears at expiry)`);
console.log('after expiry, tornado stops qualifying:', tornadoAfter.show === false, `(${tornadoAfter.reason})`);
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
process.exit(ok ? 0 : 1);

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);

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