screentinker/Examples/PIP-Room-Status-Calendar/room.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

256 lines
9.3 KiB
JavaScript

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