mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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>
248 lines
9.7 KiB
JavaScript
248 lines
9.7 KiB
JavaScript
'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();
|