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>
256 lines
9.3 KiB
JavaScript
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();
|