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>
224 lines
8.6 KiB
JavaScript
224 lines
8.6 KiB
JavaScript
'use strict';
|
|
|
|
// PIP-Welcome-Board — rotate a celebratory card (welcome / birthday / work
|
|
// anniversary) onto a ScreenTinker screen or group via the PiP overlay API,
|
|
// driven by a local CSV. Birthdays/anniversaries are shown on their day;
|
|
// 'welcome' rows show every day.
|
|
//
|
|
// node welcome.js [--config config.json]
|
|
//
|
|
// Ctrl-C (SIGINT) clears the overlay and exits.
|
|
// 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) -------------------------
|
|
|
|
const pad2 = (n) => String(n).padStart(2, '0');
|
|
|
|
// "today" as MM-DD in LOCAL time (the same basis we compare CSV dates on).
|
|
function mmddOf(now) {
|
|
return `${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`;
|
|
}
|
|
|
|
// Extract MM-DD from "MM-DD" or "YYYY-MM-DD" (returns '' if unparseable).
|
|
function mmdd(dateStr) {
|
|
const m = String(dateStr || '').trim().match(/(\d{1,2})-(\d{1,2})$/);
|
|
return m ? `${pad2(m[1])}-${pad2(m[2])}` : '';
|
|
}
|
|
|
|
// Year from "YYYY-MM-DD" (else null) — lets anniversaries show "<n> years".
|
|
function yearOf(dateStr) {
|
|
const m = String(dateStr || '').trim().match(/^(\d{4})-\d{1,2}-\d{1,2}$/);
|
|
return m ? Number(m[1]) : null;
|
|
}
|
|
|
|
// Minimal CSV parser: one record per line, comma-separated, with "quoted"
|
|
// fields that may contain commas and "" escapes. Returns array of row objects
|
|
// keyed by the header row (lower-cased). Blank lines skipped.
|
|
function parseCsv(text) {
|
|
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n').filter((l) => l.trim() !== '');
|
|
if (lines.length === 0) return [];
|
|
const parseLine = (line) => {
|
|
const out = [];
|
|
let cur = '', inQ = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const c = line[i];
|
|
if (inQ) {
|
|
if (c === '"') {
|
|
if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false;
|
|
} else cur += c;
|
|
} else if (c === '"') inQ = true;
|
|
else if (c === ',') { out.push(cur); cur = ''; }
|
|
else cur += c;
|
|
}
|
|
out.push(cur);
|
|
return out.map((s) => s.trim());
|
|
};
|
|
const header = parseLine(lines[0]).map((h) => h.toLowerCase());
|
|
return lines.slice(1).map((line) => {
|
|
const cells = parseLine(line);
|
|
const row = {};
|
|
header.forEach((h, i) => { row[h] = cells[i] !== undefined ? cells[i] : ''; });
|
|
return row;
|
|
});
|
|
}
|
|
|
|
// Which rows to show right now: dated entries (birthday/anniversary) whose
|
|
// MM-DD is today, plus every 'welcome'. If nothing qualifies and
|
|
// showAllWhenEmpty is set, fall back to all rows (so the screen isn't blank).
|
|
function todaysEntries(rows, now, opts = {}) {
|
|
const today = mmddOf(now);
|
|
const dated = rows.filter(
|
|
(r) => (r.type === 'birthday' || r.type === 'anniversary') && mmdd(r.date) === today
|
|
);
|
|
const welcomes = rows.filter((r) => r.type === 'welcome');
|
|
const result = [...dated, ...welcomes];
|
|
if (result.length === 0 && opts.showAllWhenEmpty) return rows.slice();
|
|
return result;
|
|
}
|
|
|
|
// Map a row to display fields. Accent colour by type. name is kept separate
|
|
// from the greeting so the overlay can make it prominent.
|
|
const TYPE_COLOR = { birthday: 'E8730C', anniversary: '8E44AD', welcome: '1F9D55' };
|
|
|
|
function buildMessage(entry, now) {
|
|
const name = entry.name || '';
|
|
const note = entry.note || '';
|
|
if (entry.type === 'birthday') {
|
|
return { kind: 'BIRTHDAY', emoji: '🎂', greeting: 'Happy Birthday', name, note, color: TYPE_COLOR.birthday };
|
|
}
|
|
if (entry.type === 'anniversary') {
|
|
const y = yearOf(entry.date);
|
|
const years = y != null && now ? now.getFullYear() - y : null;
|
|
const greeting = years != null && years > 0 ? `${years} Year${years === 1 ? '' : 's'}!` : 'Happy Work Anniversary';
|
|
return { kind: 'WORK ANNIVERSARY', emoji: '🎉', greeting, name, note, color: TYPE_COLOR.anniversary };
|
|
}
|
|
// default: welcome
|
|
return { kind: 'WELCOME', emoji: '👋', greeting: 'Welcome', name, note, color: TYPE_COLOR.welcome };
|
|
}
|
|
|
|
function sanitizeColor(c) {
|
|
const hex = String(c || '').replace(/[^0-9a-fA-F]/g, '');
|
|
return hex.length === 6 ? hex : '1F9D55';
|
|
}
|
|
|
|
// overlay_base_url + ?kind&emoji&greeting&name¬e&color
|
|
function buildOverlayUri(base, view) {
|
|
const q = new URLSearchParams({
|
|
kind: view.kind || '',
|
|
emoji: view.emoji || '',
|
|
greeting: view.greeting || '',
|
|
name: view.name || '',
|
|
note: view.note || '',
|
|
color: sanitizeColor(view.color),
|
|
});
|
|
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
|
}
|
|
|
|
// --- 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 = {};
|
|
const argv = process.argv.slice(2);
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--config') { args.config = argv[i + 1]; i++; }
|
|
}
|
|
const cfg = loadConfig(args.config);
|
|
|
|
const apiBase = String(cfg.api_base || '').replace(/\/$/, '');
|
|
const token = cfg.api_token;
|
|
const target = cfg.device_id;
|
|
const overlayBase = cfg.overlay_base_url;
|
|
if (!apiBase || !token || !target || !overlayBase) {
|
|
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
|
|
process.exit(1);
|
|
}
|
|
|
|
const csvFile = path.isAbsolute(cfg.csv_file || '') ? cfg.csv_file : path.join(__dirname, cfg.csv_file || 'people.csv');
|
|
let rows;
|
|
try { rows = parseCsv(fs.readFileSync(csvFile, 'utf8')); }
|
|
catch (e) { console.error(`could not read csv_file ${csvFile}: ${e.message}`); process.exit(1); }
|
|
|
|
const rotateSec = cfg.rotate_interval_sec || 12;
|
|
const position = cfg.position || 'center';
|
|
if (!POSITIONS.includes(position)) { console.error(`invalid position; use one of: ${POSITIONS.join(', ')}`); process.exit(1); }
|
|
const width = cfg.width || 820;
|
|
const height = cfg.height || 300;
|
|
const showAllWhenEmpty = cfg.show_all_when_empty !== false;
|
|
|
|
const entries = todaysEntries(rows, new Date(), { showAllWhenEmpty });
|
|
if (entries.length === 0) {
|
|
console.log('no entries to show today (and show_all_when_empty is false). Nothing to do.');
|
|
return;
|
|
}
|
|
|
|
console.log(`Welcome board — ${entries.length} card(s), rotating every ${rotateSec}s on ${target}`);
|
|
entries.forEach((e) => { const v = buildMessage(e, new Date()); console.log(` • ${v.emoji} ${v.greeting} — ${v.name}`); });
|
|
|
|
let idx = 0;
|
|
let currentPip = null;
|
|
|
|
async function showNext() {
|
|
const entry = entries[idx % entries.length];
|
|
idx++;
|
|
const view = buildMessage(entry, new Date());
|
|
const uri = buildOverlayUri(overlayBase, view);
|
|
const body = {
|
|
device_id: target, type: 'web', uri, position, width, height,
|
|
duration: 0, border_radius: 18, opacity: 1, close_button: false,
|
|
title: `${view.emoji} ${view.name}`.slice(0, 200),
|
|
};
|
|
try {
|
|
const { ok, status, json } = await postJson(`${apiBase}/api/pip`, token, body);
|
|
if (!ok || !json.pip_id) { console.error(`[${new Date().toISOString()}] show failed (${status}): ${json.error || 'unknown'}`); return; }
|
|
currentPip = json.pip_id;
|
|
console.log(`[${new Date().toISOString()}] ${view.emoji} ${view.greeting}, ${view.name} pip=${json.pip_id} sent=${json.sent} offline=${json.offline}`);
|
|
} catch (e) {
|
|
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
await showNext();
|
|
const timer = entries.length > 1 ? setInterval(showNext, rotateSec * 1000) : null;
|
|
|
|
async function shutdown() {
|
|
if (timer) clearInterval(timer);
|
|
console.log('\nclearing overlay before exit...');
|
|
try { await postJson(`${apiBase}/api/pip/clear`, token, currentPip ? { device_id: target, pip_id: currentPip } : { device_id: target }); } catch { /* best effort */ }
|
|
process.exit(0);
|
|
}
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((e) => { console.error(e.message || e); process.exit(1); });
|
|
}
|
|
|
|
module.exports = { parseCsv, mmdd, mmddOf, yearOf, todaysEntries, buildMessage, buildOverlayUri, sanitizeColor, TYPE_COLOR, POSITIONS };
|