screentinker/Examples/PIP-Weather-Widget/weather.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

200 lines
8.1 KiB
JavaScript

'use strict';
// Open-Meteo -> ScreenTinker PiP weather widget.
//
// Polls api.open-meteo.com (NO API KEY) for current conditions + today's high/low and
// pushes a small persistent web overlay to a screen (or group). Re-pushes on each poll;
// the player keeps a single overlay slot (last-show-wins), so the widget just updates in
// place. Pushed with duration 0 (stays until we clear it). On exit it clears itself.
//
// node weather.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');
// --- WMO weather code -> {emoji, text}. Day/night swaps the emoji for clear-ish codes. ---
const WMO = {
0: { text: 'Clear', day: '☀️', night: '🌑' }, // ☀️ / 🌑
1: { text: 'Mainly clear', day: '🌤️', night: '🌑' }, // 🌤️
2: { text: 'Partly cloudy', day: '⛅', night: '☁️' }, // ⛅ / ☁️
3: { text: 'Overcast', emoji: '☁️' }, // ☁️
45: { text: 'Fog', emoji: '🌫️' }, // 🌫️
48: { text: 'Rime fog', emoji: '🌫️' },
51: { text: 'Light drizzle', emoji: '🌦️' }, // 🌦️
53: { text: 'Drizzle', emoji: '🌦️' },
55: { text: 'Heavy drizzle', emoji: '🌦️' },
56: { text: 'Freezing drizzle', emoji: '🌧️' }, // 🌧️
57: { text: 'Freezing drizzle', emoji: '🌧️' },
61: { text: 'Light rain', emoji: '🌧️' },
63: { text: 'Rain', emoji: '🌧️' },
65: { text: 'Heavy rain', emoji: '🌧️' },
66: { text: 'Freezing rain', emoji: '🌧️' },
67: { text: 'Freezing rain', emoji: '🌧️' },
71: { text: 'Light snow', emoji: '🌨️' }, // 🌨️
73: { text: 'Snow', emoji: '🌨️' },
75: { text: 'Heavy snow', emoji: '🌨️' },
77: { text: 'Snow grains', emoji: '🌨️' },
80: { text: 'Rain showers', emoji: '🌦️' },
81: { text: 'Rain showers', emoji: '🌦️' },
82: { text: 'Violent showers', emoji: '🌧️' },
85: { text: 'Snow showers', emoji: '🌨️' },
86: { text: 'Snow showers', emoji: '🌨️' },
95: { text: 'Thunderstorm', emoji: '⛈️' }, // ⛈️
96: { text: 'Thunderstorm, hail', emoji: '⛈️' },
99: { text: 'Thunderstorm, hail', emoji: '⛈️' },
};
function wmoToCondition(code, isDay = true) {
const e = WMO[code];
if (!e) return { emoji: '🌡️', text: 'Unknown' }; // 🌡️
const emoji = e.emoji || (isDay ? e.day : e.night);
return { emoji, text: e.text };
}
// Unit labels derived from the config's `units`. metric -> °C / km/h, imperial -> °F / mph.
function unitsFor(cfg) {
return (cfg.units || 'metric').toLowerCase() === 'imperial'
? { temp_unit: 'fahrenheit', wind_unit: 'mph', tempSym: '°F', windSym: 'mph' }
: { temp_unit: 'celsius', wind_unit: 'kmh', tempSym: '°C', windSym: 'km/h' };
}
// Pure normaliser: Open-Meteo forecast JSON -> the overlay's display view.
function normalise(data, cfg = {}) {
const u = unitsFor(cfg);
const cur = (data && data.current) || {};
const daily = (data && data.daily) || {};
const isDay = cur.is_day == null ? true : Number(cur.is_day) === 1;
const cond = wmoToCondition(Number(cur.weather_code), isDay);
const round = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v)));
const hiArr = daily.temperature_2m_max || [];
const loArr = daily.temperature_2m_min || [];
return {
location: cfg.location_name || 'Weather',
tempNow: round(cur.temperature_2m),
feelsLike: round(cur.apparent_temperature),
hi: round(hiArr[0]),
lo: round(loArr[0]),
condition: cond.text,
emoji: cond.emoji,
humidity: round(cur.relative_humidity_2m),
wind: round(cur.wind_speed_10m),
isDay,
tempUnit: u.tempSym,
windUnit: u.windSym,
updated: cur.time || '',
};
}
function forecastUrl(cfg) {
const u = unitsFor(cfg);
const q = new URLSearchParams({
latitude: String(cfg.lat),
longitude: String(cfg.lon),
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m',
daily: 'temperature_2m_max,temperature_2m_min',
timezone: 'auto',
temperature_unit: u.temp_unit,
wind_speed_unit: u.wind_unit,
forecast_days: '1',
});
return `https://api.open-meteo.com/v1/forecast?${q.toString()}`;
}
function overlayUri(base, view) {
const q = new URLSearchParams({
location: view.location || '',
temp: view.tempNow == null ? '' : String(view.tempNow),
feels: view.feelsLike == null ? '' : String(view.feelsLike),
hi: view.hi == null ? '' : String(view.hi),
lo: view.lo == null ? '' : String(view.lo),
cond: view.condition || '',
emoji: view.emoji || '',
humidity: view.humidity == null ? '' : String(view.humidity),
wind: view.wind == null ? '' : String(view.wind),
tempunit: view.tempUnit || '',
windunit: view.windUnit || '',
updated: view.updated || '',
day: view.isDay ? '1' : '0',
});
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
module.exports = { WMO, wmoToCondition, unitsFor, normalise, forecastUrl, overlayUri };
// ---- live runner (skipped when this file is require()'d by the test) ----
if (require.main === module) {
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 API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const POLL_SEC = cfg.poll_interval_sec || 600;
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE || cfg.lat == null || cfg.lon == null) {
console.error('config must set api_base, api_token, overlay_base_url, device_id, lat, lon.');
process.exit(1);
}
let pipId = null;
async function pipShow(view) {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, view),
position: cfg.position || 'top-right',
width: cfg.width || 360, height: cfg.height || 190,
duration: 0, opacity: cfg.opacity != null ? cfg.opacity : 1,
border_radius: cfg.border_radius != null ? cfg.border_radius : 16,
close_button: false,
};
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() {
if (!pipId) return;
await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
}).catch(() => {});
}
async function tick() {
try {
const res = await fetch(forecastUrl(cfg), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`Open-Meteo HTTP ${res.status}`);
const view = normalise(await res.json(), cfg);
pipId = await pipShow(view);
console.log(`[${new Date().toISOString()}] ${view.location}: ${view.tempNow}${view.tempUnit} ${view.emoji} ${view.condition} (hi ${view.hi} / lo ${view.lo}) pip=${pipId}`);
} catch (e) {
console.error(`[${new Date().toISOString()}] update error: ${e.message}`);
}
}
async function main() {
console.log(`Weather PiP widget — ${cfg.location_name || `${cfg.lat},${cfg.lon}`}, every ${POLL_SEC}s, ${cfg.position || 'top-right'}`);
await tick();
const timer = setInterval(tick, POLL_SEC * 1000);
async function shutdown() {
clearInterval(timer);
console.log('\nclearing overlay before exit...');
await pipClear();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main();
}