screentinker/Examples/PIP-Announce-Broadcast/announce.js
screentinker 0b138f10c6
Add PiP overlay example recipes (#132)
Self-contained examples for the PiP overlay API (POST /api/pip), each
with a CSP-safe query-param overlay (external JS), config.example.json,
zero runtime deps, an offline test, and a README:

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

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:20:37 -05:00

142 lines
5.3 KiB
JavaScript

'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 };