From ab771ec59543fa51442ac0491b63c0674d474cfb Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 18 Jun 2026 20:17:38 -0500 Subject: [PATCH] Add PiP overlay example recipes 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) --- Examples/PIP-Air-Quality/.gitignore | 3 + Examples/PIP-Air-Quality/README.md | 89 +++++ Examples/PIP-Air-Quality/aqi-overlay.html | 44 +++ Examples/PIP-Air-Quality/aqi-overlay.js | 38 ++ Examples/PIP-Air-Quality/aqi.js | 149 ++++++++ Examples/PIP-Air-Quality/config.example.json | 16 + Examples/PIP-Air-Quality/fixture-aqi.json | 21 + Examples/PIP-Air-Quality/package.json | 12 + Examples/PIP-Air-Quality/test.js | 58 +++ Examples/PIP-Announce-Broadcast/.gitignore | 3 + Examples/PIP-Announce-Broadcast/README.md | 89 +++++ Examples/PIP-Announce-Broadcast/announce.js | 141 +++++++ .../config.example.json | 17 + .../message-overlay.html | 30 ++ .../PIP-Announce-Broadcast/message-overlay.js | 23 ++ Examples/PIP-Announce-Broadcast/package.json | 12 + Examples/PIP-Announce-Broadcast/test.js | 42 ++ Examples/PIP-CAP-AU-Alert-Monitor/.gitignore | 3 + Examples/PIP-CAP-AU-Alert-Monitor/README.md | 92 +++++ .../alert-overlay.html | 40 ++ .../PIP-CAP-AU-Alert-Monitor/cap-parse.js | 183 +++++++++ .../config.example.json | 29 ++ .../PIP-CAP-AU-Alert-Monitor/fixture-feed.xml | 82 ++++ Examples/PIP-CAP-AU-Alert-Monitor/monitor.js | 175 +++++++++ Examples/PIP-CAP-AU-Alert-Monitor/overlay.js | 28 ++ .../PIP-CAP-AU-Alert-Monitor/package.json | 15 + .../PIP-CAP-AU-Alert-Monitor/test-parse.js | 43 +++ Examples/PIP-Crypto-Ticker/.gitignore | 3 + Examples/PIP-Crypto-Ticker/README.md | 112 ++++++ .../PIP-Crypto-Ticker/config.example.json | 21 + .../PIP-Crypto-Ticker/fixture-prices.json | 6 + Examples/PIP-Crypto-Ticker/package.json | 12 + Examples/PIP-Crypto-Ticker/test.js | 62 +++ .../PIP-Crypto-Ticker/ticker-overlay.html | 31 ++ Examples/PIP-Crypto-Ticker/ticker-overlay.js | 62 +++ Examples/PIP-Crypto-Ticker/ticker.js | 209 ++++++++++ Examples/PIP-Event-Countdown/.gitignore | 3 + Examples/PIP-Event-Countdown/README.md | 103 +++++ .../PIP-Event-Countdown/config.example.json | 11 + .../countdown-overlay.html | 41 ++ .../PIP-Event-Countdown/countdown-overlay.js | 53 +++ Examples/PIP-Event-Countdown/countdown.js | 156 ++++++++ Examples/PIP-Event-Countdown/package.json | 12 + Examples/PIP-Event-Countdown/test.js | 44 +++ .../PIP-Fundraiser-Thermometer/.gitignore | 3 + Examples/PIP-Fundraiser-Thermometer/README.md | 96 +++++ .../config.example.json | 16 + .../PIP-Fundraiser-Thermometer/package.json | 12 + .../progress.example.json | 6 + Examples/PIP-Fundraiser-Thermometer/test.js | 54 +++ .../thermo-overlay.html | 46 +++ .../thermo-overlay.js | 32 ++ Examples/PIP-Fundraiser-Thermometer/thermo.js | 170 +++++++++ Examples/PIP-Incident-Webhook/.gitignore | 3 + Examples/PIP-Incident-Webhook/README.md | 108 ++++++ .../PIP-Incident-Webhook/config.example.json | 15 + .../incident-overlay.html | 40 ++ .../PIP-Incident-Webhook/incident-overlay.js | 23 ++ Examples/PIP-Incident-Webhook/package.json | 12 + Examples/PIP-Incident-Webhook/server.js | 235 ++++++++++++ Examples/PIP-Incident-Webhook/test.js | 55 +++ Examples/PIP-News-Ticker/.gitignore | 3 + Examples/PIP-News-Ticker/README.md | 96 +++++ Examples/PIP-News-Ticker/config.example.json | 18 + Examples/PIP-News-Ticker/fixture-feed.xml | 28 ++ Examples/PIP-News-Ticker/news-overlay.html | 31 ++ Examples/PIP-News-Ticker/news-overlay.js | 60 +++ Examples/PIP-News-Ticker/news.js | 166 ++++++++ Examples/PIP-News-Ticker/package.json | 12 + Examples/PIP-News-Ticker/test.js | 44 +++ Examples/PIP-QR-Rotator/.gitignore | 3 + Examples/PIP-QR-Rotator/README.md | 110 ++++++ Examples/PIP-QR-Rotator/config.example.json | 18 + Examples/PIP-QR-Rotator/package.json | 12 + Examples/PIP-QR-Rotator/qr-overlay.html | 32 ++ Examples/PIP-QR-Rotator/qr-overlay.js | 361 ++++++++++++++++++ Examples/PIP-QR-Rotator/qr.js | 153 ++++++++ Examples/PIP-QR-Rotator/test.js | 72 ++++ Examples/PIP-Room-Status-Calendar/.gitignore | 3 + Examples/PIP-Room-Status-Calendar/README.md | 107 ++++++ .../config.example.json | 17 + .../PIP-Room-Status-Calendar/fixture-room.ics | 30 ++ .../PIP-Room-Status-Calendar/package.json | 12 + .../room-overlay.html | 33 ++ .../PIP-Room-Status-Calendar/room-overlay.js | 13 + Examples/PIP-Room-Status-Calendar/room.js | 255 +++++++++++++ Examples/PIP-Room-Status-Calendar/test.js | 45 +++ .../PIP-USD-Cap-Alert-Monitor-NOAA/.gitignore | 4 + .../alert-overlay.html | 36 ++ .../cap-parse.js | 183 +++++++++ .../config.noaa.example.json | 21 + .../fixture-feed.xml | 82 ++++ .../fixture-noaa.json | 90 +++++ .../make-demo-alert.js | 25 ++ .../PIP-USD-Cap-Alert-Monitor-NOAA/monitor.js | 247 ++++++++++++ .../noaa-parse.js | 103 +++++ .../PIP-USD-Cap-Alert-Monitor-NOAA/overlay.js | 29 ++ .../package.json | 15 + .../test-noaa.js | 47 +++ .../test-parse.js | 43 +++ Examples/PIP-Weather-Widget/.gitignore | 3 + Examples/PIP-Weather-Widget/README.md | 89 +++++ .../PIP-Weather-Widget/config.example.json | 18 + .../PIP-Weather-Widget/fixture-weather.json | 29 ++ Examples/PIP-Weather-Widget/package.json | 12 + Examples/PIP-Weather-Widget/test.js | 56 +++ .../PIP-Weather-Widget/weather-overlay.html | 43 +++ .../PIP-Weather-Widget/weather-overlay.js | 32 ++ Examples/PIP-Weather-Widget/weather.js | 199 ++++++++++ Examples/PIP-Welcome-Board/.gitignore | 4 + Examples/PIP-Welcome-Board/README.md | 97 +++++ .../PIP-Welcome-Board/config.example.json | 14 + Examples/PIP-Welcome-Board/package.json | 12 + Examples/PIP-Welcome-Board/people.example.csv | 7 + Examples/PIP-Welcome-Board/test.js | 77 ++++ .../PIP-Welcome-Board/welcome-overlay.html | 34 ++ Examples/PIP-Welcome-Board/welcome-overlay.js | 23 ++ Examples/PIP-Welcome-Board/welcome.js | 223 +++++++++++ 118 files changed, 6975 insertions(+) create mode 100644 Examples/PIP-Air-Quality/.gitignore create mode 100644 Examples/PIP-Air-Quality/README.md create mode 100644 Examples/PIP-Air-Quality/aqi-overlay.html create mode 100644 Examples/PIP-Air-Quality/aqi-overlay.js create mode 100644 Examples/PIP-Air-Quality/aqi.js create mode 100644 Examples/PIP-Air-Quality/config.example.json create mode 100644 Examples/PIP-Air-Quality/fixture-aqi.json create mode 100644 Examples/PIP-Air-Quality/package.json create mode 100644 Examples/PIP-Air-Quality/test.js create mode 100644 Examples/PIP-Announce-Broadcast/.gitignore create mode 100644 Examples/PIP-Announce-Broadcast/README.md create mode 100644 Examples/PIP-Announce-Broadcast/announce.js create mode 100644 Examples/PIP-Announce-Broadcast/config.example.json create mode 100644 Examples/PIP-Announce-Broadcast/message-overlay.html create mode 100644 Examples/PIP-Announce-Broadcast/message-overlay.js create mode 100644 Examples/PIP-Announce-Broadcast/package.json create mode 100644 Examples/PIP-Announce-Broadcast/test.js create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/.gitignore create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/README.md create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/alert-overlay.html create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/cap-parse.js create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/config.example.json create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/fixture-feed.xml create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/monitor.js create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/overlay.js create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/package.json create mode 100644 Examples/PIP-CAP-AU-Alert-Monitor/test-parse.js create mode 100644 Examples/PIP-Crypto-Ticker/.gitignore create mode 100644 Examples/PIP-Crypto-Ticker/README.md create mode 100644 Examples/PIP-Crypto-Ticker/config.example.json create mode 100644 Examples/PIP-Crypto-Ticker/fixture-prices.json create mode 100644 Examples/PIP-Crypto-Ticker/package.json create mode 100644 Examples/PIP-Crypto-Ticker/test.js create mode 100644 Examples/PIP-Crypto-Ticker/ticker-overlay.html create mode 100644 Examples/PIP-Crypto-Ticker/ticker-overlay.js create mode 100644 Examples/PIP-Crypto-Ticker/ticker.js create mode 100644 Examples/PIP-Event-Countdown/.gitignore create mode 100644 Examples/PIP-Event-Countdown/README.md create mode 100644 Examples/PIP-Event-Countdown/config.example.json create mode 100644 Examples/PIP-Event-Countdown/countdown-overlay.html create mode 100644 Examples/PIP-Event-Countdown/countdown-overlay.js create mode 100644 Examples/PIP-Event-Countdown/countdown.js create mode 100644 Examples/PIP-Event-Countdown/package.json create mode 100644 Examples/PIP-Event-Countdown/test.js create mode 100644 Examples/PIP-Fundraiser-Thermometer/.gitignore create mode 100644 Examples/PIP-Fundraiser-Thermometer/README.md create mode 100644 Examples/PIP-Fundraiser-Thermometer/config.example.json create mode 100644 Examples/PIP-Fundraiser-Thermometer/package.json create mode 100644 Examples/PIP-Fundraiser-Thermometer/progress.example.json create mode 100644 Examples/PIP-Fundraiser-Thermometer/test.js create mode 100644 Examples/PIP-Fundraiser-Thermometer/thermo-overlay.html create mode 100644 Examples/PIP-Fundraiser-Thermometer/thermo-overlay.js create mode 100644 Examples/PIP-Fundraiser-Thermometer/thermo.js create mode 100644 Examples/PIP-Incident-Webhook/.gitignore create mode 100644 Examples/PIP-Incident-Webhook/README.md create mode 100644 Examples/PIP-Incident-Webhook/config.example.json create mode 100644 Examples/PIP-Incident-Webhook/incident-overlay.html create mode 100644 Examples/PIP-Incident-Webhook/incident-overlay.js create mode 100644 Examples/PIP-Incident-Webhook/package.json create mode 100644 Examples/PIP-Incident-Webhook/server.js create mode 100644 Examples/PIP-Incident-Webhook/test.js create mode 100644 Examples/PIP-News-Ticker/.gitignore create mode 100644 Examples/PIP-News-Ticker/README.md create mode 100644 Examples/PIP-News-Ticker/config.example.json create mode 100644 Examples/PIP-News-Ticker/fixture-feed.xml create mode 100644 Examples/PIP-News-Ticker/news-overlay.html create mode 100644 Examples/PIP-News-Ticker/news-overlay.js create mode 100644 Examples/PIP-News-Ticker/news.js create mode 100644 Examples/PIP-News-Ticker/package.json create mode 100644 Examples/PIP-News-Ticker/test.js create mode 100644 Examples/PIP-QR-Rotator/.gitignore create mode 100644 Examples/PIP-QR-Rotator/README.md create mode 100644 Examples/PIP-QR-Rotator/config.example.json create mode 100644 Examples/PIP-QR-Rotator/package.json create mode 100644 Examples/PIP-QR-Rotator/qr-overlay.html create mode 100644 Examples/PIP-QR-Rotator/qr-overlay.js create mode 100644 Examples/PIP-QR-Rotator/qr.js create mode 100644 Examples/PIP-QR-Rotator/test.js create mode 100644 Examples/PIP-Room-Status-Calendar/.gitignore create mode 100644 Examples/PIP-Room-Status-Calendar/README.md create mode 100644 Examples/PIP-Room-Status-Calendar/config.example.json create mode 100644 Examples/PIP-Room-Status-Calendar/fixture-room.ics create mode 100644 Examples/PIP-Room-Status-Calendar/package.json create mode 100644 Examples/PIP-Room-Status-Calendar/room-overlay.html create mode 100644 Examples/PIP-Room-Status-Calendar/room-overlay.js create mode 100644 Examples/PIP-Room-Status-Calendar/room.js create mode 100644 Examples/PIP-Room-Status-Calendar/test.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/.gitignore create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/alert-overlay.html create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/cap-parse.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/config.noaa.example.json create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-feed.xml create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-noaa.json create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/make-demo-alert.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/monitor.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/noaa-parse.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/overlay.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/package.json create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/test-noaa.js create mode 100644 Examples/PIP-USD-Cap-Alert-Monitor-NOAA/test-parse.js create mode 100644 Examples/PIP-Weather-Widget/.gitignore create mode 100644 Examples/PIP-Weather-Widget/README.md create mode 100644 Examples/PIP-Weather-Widget/config.example.json create mode 100644 Examples/PIP-Weather-Widget/fixture-weather.json create mode 100644 Examples/PIP-Weather-Widget/package.json create mode 100644 Examples/PIP-Weather-Widget/test.js create mode 100644 Examples/PIP-Weather-Widget/weather-overlay.html create mode 100644 Examples/PIP-Weather-Widget/weather-overlay.js create mode 100644 Examples/PIP-Weather-Widget/weather.js create mode 100644 Examples/PIP-Welcome-Board/.gitignore create mode 100644 Examples/PIP-Welcome-Board/README.md create mode 100644 Examples/PIP-Welcome-Board/config.example.json create mode 100644 Examples/PIP-Welcome-Board/package.json create mode 100644 Examples/PIP-Welcome-Board/people.example.csv create mode 100644 Examples/PIP-Welcome-Board/test.js create mode 100644 Examples/PIP-Welcome-Board/welcome-overlay.html create mode 100644 Examples/PIP-Welcome-Board/welcome-overlay.js create mode 100644 Examples/PIP-Welcome-Board/welcome.js diff --git a/Examples/PIP-Air-Quality/.gitignore b/Examples/PIP-Air-Quality/.gitignore new file mode 100644 index 0000000..660c6d9 --- /dev/null +++ b/Examples/PIP-Air-Quality/.gitignore @@ -0,0 +1,3 @@ +config.json +node_modules/ +package-lock.json diff --git a/Examples/PIP-Air-Quality/README.md b/Examples/PIP-Air-Quality/README.md new file mode 100644 index 0000000..0be5491 --- /dev/null +++ b/Examples/PIP-Air-Quality/README.md @@ -0,0 +1,89 @@ +# PiP Air-Quality Widget + +A persistent corner **air-quality widget** for ScreenTinker screens, driven by the +**[Open-Meteo Air Quality API](https://open-meteo.com/en/docs/air-quality-api)** — no API key, +no signup. Shows the current **US AQI** (color-coded by EPA band) plus the component +pollutants (PM2.5 / PM10 / O₃ / NO₂) and refreshes itself in place. + +``` +Open-Meteo Air Quality ──poll──▶ aqi.js ──POST /api/pip──▶ ScreenTinker ──ws──▶ player + (us_aqi, pm2.5, …) (normalise + color) (web overlay) (corner widget) +``` + +It pushes a `type: web` overlay with `duration: 0` (stays up until cleared) and re-pushes +each poll; the player keeps a single overlay slot (last-show-wins) so the widget just updates. +On `Ctrl-C` it clears the overlay. + +## How it works + +- **`aqi.js`** — polls Open-Meteo, normalises the response, maps the US AQI to an EPA category + + color, and pushes/refreshes the overlay. Pure helpers (`aqiCategory`, `normalise`, + `aqiUrl`, `overlayUri`) are exported for the test. +- **`aqi-overlay.html` + `aqi-overlay.js`** — the overlay page rendered in the player's iframe. + All data comes from the URL query string; the JS is external (no inline script) so it passes + the signage server's CSP (`scriptSrc 'self'`). + +### US EPA AQI bands + +| US AQI | Category | Color | +|---|---|---| +| 0–50 | Good | `#1f9d55` | +| 51–100 | Moderate | `#F2C200` | +| 101–150 | Unhealthy (Sensitive) | `#E8730C` | +| 151–200 | Unhealthy | `#CC0000` | +| 201–300 | Very Unhealthy | `#7B0000` | +| 301+ | Hazardous | `#5B0000` | + +## Setup + +1. **Host the overlay page.** Copy both `aqi-overlay.html` and `aqi-overlay.js` into your + signage server's frontend directory so they're served same-origin as the player (required by + the CSP). They'll be reachable at `https:///aqi-overlay.html`. + +2. **Get a `full`-scope API token** (`st_…`) from the dashboard. + +3. **Configure.** Copy `config.example.json` → `config.json` and fill in: + - `api_base` — your ScreenTinker server, e.g. `https://signage.example.com` + - `api_token` — the `st_…` token + - `overlay_base_url` — `https:///aqi-overlay.html` + - `device_id` — a device **or** group id + - `lat` / `lon` / `location_name` — the location to report + - optional: `poll_interval_sec` (default 900), `position` (default `top-right`), + `width`/`height`, `border_radius` + +4. **Run:** + ```bash + node aqi.js + ``` + Leave it running; it refreshes every `poll_interval_sec`. `Ctrl-C` clears the overlay. + +## Test (offline, no network) + +```bash +npm test +``` +Checks the EPA band boundaries, the category→color map, and the normaliser against +`fixture-aqi.json`. Prints `RESULT: PASS ✅`. + +## Local quick-start (this machine) + +The local dev instance serves the player over self-signed HTTPS, so disable TLS verification: + +```bash +# 1. copy the overlay assets into the local server's frontend dir, e.g.: +cp aqi-overlay.html aqi-overlay.js /home/owner/Downloads/remote_display/frontend/ + +# 2. config.json for the local "testing" player: +# api_base https://localhost:3443/ +# api_token st_REPLACE_WITH_A_FULL_SCOPE_TOKEN +# overlay_base_url https://localhost:3443/aqi-overlay.html +# device_id DEVICE_OR_GROUP_ID + +NODE_TLS_REJECT_UNAUTHORIZED=0 node aqi.js +``` + +## Notes + +- Open-Meteo's `us_aqi` is the **overall** US AQI (max of the per-pollutant sub-indices). +- The free Open-Meteo API is rate-limited; a 900s (15 min) poll is plenty for air quality. +- `config.json` is gitignored (it holds your token). diff --git a/Examples/PIP-Air-Quality/aqi-overlay.html b/Examples/PIP-Air-Quality/aqi-overlay.html new file mode 100644 index 0000000..48ceafb --- /dev/null +++ b/Examples/PIP-Air-Quality/aqi-overlay.html @@ -0,0 +1,44 @@ + + + + + +Air Quality + + + +
+
+ + +
+
+ + US AQI +
+
+
+
+
+ + + diff --git a/Examples/PIP-Air-Quality/aqi-overlay.js b/Examples/PIP-Air-Quality/aqi-overlay.js new file mode 100644 index 0000000..c6c23b0 --- /dev/null +++ b/Examples/PIP-Air-Quality/aqi-overlay.js @@ -0,0 +1,38 @@ +// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it. +// Reads the air-quality fields from the URL query string and populates the widget. +(function () { + var q = new URLSearchParams(location.search); + var get = function (k) { return (q.get(k) || '').trim(); }; + var set = function (id, txt) { var el = document.getElementById(id); if (el) el.textContent = txt; }; + + var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || '888888'); + + set('loc', get('location') || 'Air Quality'); + set('aqi', get('aqi') !== '' ? get('aqi') : '--'); + set('cat', get('category') || ''); + + // Category color drives the AQI number, the left accent, and a pill badge. + document.getElementById('aqi').style.color = color; + document.getElementById('card').style.borderLeftColor = color; + var badge = document.getElementById('badge'); + if (get('category')) { badge.textContent = get('category'); badge.style.background = color; } + + var parts = []; + if (get('pm25') !== '') parts.push('PM2.5 ' + esc(get('pm25'))); + if (get('pm10') !== '') parts.push('PM10 ' + esc(get('pm10'))); + if (get('ozone') !== '') parts.push('O₃ ' + esc(get('ozone'))); + if (get('no2') !== '') parts.push('NO₂ ' + esc(get('no2'))); + document.getElementById('grid').innerHTML = parts.join(''); + + var updated = get('updated'); + if (updated) { + var d = new Date(updated); + set('updated', isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))); + } + + function esc(s) { + return s.replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } +})(); diff --git a/Examples/PIP-Air-Quality/aqi.js b/Examples/PIP-Air-Quality/aqi.js new file mode 100644 index 0000000..2183845 --- /dev/null +++ b/Examples/PIP-Air-Quality/aqi.js @@ -0,0 +1,149 @@ +'use strict'; + +// Open-Meteo Air Quality -> ScreenTinker PiP air-quality widget. +// +// Polls air-quality-api.open-meteo.com (NO API KEY) for the current US AQI plus the +// component pollutants, 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 updates in place. Pushed with duration 0 (stays until cleared). Clears on exit. +// +// node aqi.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'); + +// US EPA AQI bands -> { label, color }. Boundaries are inclusive of the upper value +// (0-50 Good, 51-100 Moderate, ...). 301+ is Hazardous. +function aqiCategory(aqi) { + const n = Number(aqi); + if (!Number.isFinite(n)) return { label: 'Unknown', color: '#888888' }; + if (n <= 50) return { label: 'Good', color: '#1f9d55' }; + if (n <= 100) return { label: 'Moderate', color: '#F2C200' }; + if (n <= 150) return { label: 'Unhealthy (Sensitive)', color: '#E8730C' }; + if (n <= 200) return { label: 'Unhealthy', color: '#CC0000' }; + if (n <= 300) return { label: 'Very Unhealthy', color: '#7B0000' }; + return { label: 'Hazardous', color: '#5B0000' }; +} + +// Pure normaliser: Open-Meteo air-quality JSON -> the overlay's display view. +function normalise(data, cfg = {}) { + const cur = (data && data.current) || {}; + const round = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v))); + const usAqi = round(cur.us_aqi); + const cat = aqiCategory(usAqi); + return { + location: cfg.location_name || 'Air Quality', + usAqi, + category: cat.label, + color: cat.color, + pm25: round(cur.pm2_5), + pm10: round(cur.pm10), + ozone: round(cur.ozone), + no2: round(cur.nitrogen_dioxide), + updated: cur.time || '', + }; +} + +function aqiUrl(cfg) { + const q = new URLSearchParams({ + latitude: String(cfg.lat), + longitude: String(cfg.lon), + current: 'us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide', + timezone: 'auto', + }); + return `https://air-quality-api.open-meteo.com/v1/air-quality?${q.toString()}`; +} + +function overlayUri(base, view) { + const q = new URLSearchParams({ + location: view.location || '', + aqi: view.usAqi == null ? '' : String(view.usAqi), + category: view.category || '', + color: (view.color || '#888888').replace(/[^0-9a-fA-F]/g, ''), + pm25: view.pm25 == null ? '' : String(view.pm25), + pm10: view.pm10 == null ? '' : String(view.pm10), + ozone: view.ozone == null ? '' : String(view.ozone), + no2: view.no2 == null ? '' : String(view.no2), + updated: view.updated || '', + }); + return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`; +} + +module.exports = { aqiCategory, normalise, aqiUrl, 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 || 900; + 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 || 200, + 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(aqiUrl(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}: AQI ${view.usAqi} (${view.category}) pm2.5=${view.pm25} pm10=${view.pm10} pip=${pipId}`); + } catch (e) { + console.error(`[${new Date().toISOString()}] update error: ${e.message}`); + } + } + + async function main() { + console.log(`Air-Quality 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(); +} diff --git a/Examples/PIP-Air-Quality/config.example.json b/Examples/PIP-Air-Quality/config.example.json new file mode 100644 index 0000000..2e482f5 --- /dev/null +++ b/Examples/PIP-Air-Quality/config.example.json @@ -0,0 +1,16 @@ +{ + "api_base": "https://signage.example.com", + "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN", + "overlay_base_url": "https://signage.example.com/aqi-overlay.html", + "device_id": "DEVICE_OR_GROUP_ID", + + "lat": 45.5152, + "lon": -122.6784, + "location_name": "Portland, OR", + + "poll_interval_sec": 900, + "position": "top-right", + "width": 360, + "height": 200, + "border_radius": 16 +} diff --git a/Examples/PIP-Air-Quality/fixture-aqi.json b/Examples/PIP-Air-Quality/fixture-aqi.json new file mode 100644 index 0000000..db518de --- /dev/null +++ b/Examples/PIP-Air-Quality/fixture-aqi.json @@ -0,0 +1,21 @@ +{ + "latitude": 45.5, + "longitude": -122.5, + "timezone": "America/Los_Angeles", + "current_units": { + "time": "iso8601", + "us_aqi": "USAQI", + "pm2_5": "μg/m³", + "pm10": "μg/m³", + "ozone": "μg/m³", + "nitrogen_dioxide": "μg/m³" + }, + "current": { + "time": "2026-06-18T10:00", + "us_aqi": 72, + "pm2_5": 23.4, + "pm10": 31.2, + "ozone": 88.0, + "nitrogen_dioxide": 12.4 + } +} diff --git a/Examples/PIP-Air-Quality/package.json b/Examples/PIP-Air-Quality/package.json new file mode 100644 index 0000000..4807b09 --- /dev/null +++ b/Examples/PIP-Air-Quality/package.json @@ -0,0 +1,12 @@ +{ + "name": "pip-air-quality", + "version": "0.1.0", + "description": "Example: a persistent ScreenTinker PiP air-quality widget driven by the keyless Open-Meteo Air Quality API.", + "type": "commonjs", + "main": "aqi.js", + "scripts": { + "start": "node aqi.js", + "test": "node test.js" + }, + "engines": { "node": ">=18" } +} diff --git a/Examples/PIP-Air-Quality/test.js b/Examples/PIP-Air-Quality/test.js new file mode 100644 index 0000000..cff0ce0 --- /dev/null +++ b/Examples/PIP-Air-Quality/test.js @@ -0,0 +1,58 @@ +'use strict'; + +// Offline test: US EPA AQI band boundaries + the Open-Meteo normaliser, against +// fixture-aqi.json. No network, no API token. Prints "RESULT: PASS ✅", exits 0 on success. + +const fs = require('fs'); +const a = require('./aqi'); + +const data = JSON.parse(fs.readFileSync('./fixture-aqi.json', 'utf8')); +const view = a.normalise(data, { location_name: 'Portland, OR' }); + +console.log('normalised view:'); +console.log(view); + +console.log('\n--- AQI band boundaries ---'); +const bands = [ + [0, 'Good'], [50, 'Good'], [51, 'Moderate'], [100, 'Moderate'], + [101, 'Unhealthy (Sensitive)'], [150, 'Unhealthy (Sensitive)'], + [151, 'Unhealthy'], [200, 'Unhealthy'], + [201, 'Very Unhealthy'], [300, 'Very Unhealthy'], [301, 'Hazardous'], [500, 'Hazardous'], +]; +for (const [n, label] of bands) console.log(`${String(n).padStart(3)} -> ${a.aqiCategory(n).label}`); + +const checks = { + '0 -> Good': a.aqiCategory(0).label === 'Good', + '50 -> Good (upper bound)': a.aqiCategory(50).label === 'Good', + '51 -> Moderate': a.aqiCategory(51).label === 'Moderate', + '100 -> Moderate (upper bound)': a.aqiCategory(100).label === 'Moderate', + '101 -> Unhealthy (Sensitive)': a.aqiCategory(101).label === 'Unhealthy (Sensitive)', + '150 -> Unhealthy (Sensitive) (upper bound)': a.aqiCategory(150).label === 'Unhealthy (Sensitive)', + '200 -> Unhealthy (upper bound)': a.aqiCategory(200).label === 'Unhealthy', + '201 -> Very Unhealthy': a.aqiCategory(201).label === 'Very Unhealthy', + '301 -> Hazardous': a.aqiCategory(301).label === 'Hazardous', + 'Good color': a.aqiCategory(25).color === '#1f9d55', + 'Moderate color': a.aqiCategory(72).color === '#F2C200', + 'Hazardous color': a.aqiCategory(400).color === '#5B0000', + 'unknown AQI falls back': a.aqiCategory(undefined).label === 'Unknown', + + 'usAqi from fixture': view.usAqi === 72, + 'category from fixture': view.category === 'Moderate', + 'color matches category': view.color === '#F2C200', + 'pm25 rounded': view.pm25 === 23, + 'pm10 rounded': view.pm10 === 31, + 'ozone rounded': view.ozone === 88, + 'no2 rounded': view.no2 === 12, + 'location passthrough': view.location === 'Portland, OR', + 'updated passthrough': view.updated === '2026-06-18T10:00', +}; + +console.log('\n--- assertions ---'); +let ok = true; +for (const [name, pass] of Object.entries(checks)) { + console.log(`${pass ? '✓' : '✗'} ${name}`); + if (!pass) ok = false; +} + +console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌'); +process.exit(ok ? 0 : 1); diff --git a/Examples/PIP-Announce-Broadcast/.gitignore b/Examples/PIP-Announce-Broadcast/.gitignore new file mode 100644 index 0000000..660c6d9 --- /dev/null +++ b/Examples/PIP-Announce-Broadcast/.gitignore @@ -0,0 +1,3 @@ +config.json +node_modules/ +package-lock.json diff --git a/Examples/PIP-Announce-Broadcast/README.md b/Examples/PIP-Announce-Broadcast/README.md new file mode 100644 index 0000000..3d36bbd --- /dev/null +++ b/Examples/PIP-Announce-Broadcast/README.md @@ -0,0 +1,89 @@ +# PiP Announce / Broadcast + +Flash a one-off text announcement onto a ScreenTinker screen (or a whole group) using +the **PiP overlay API**, then clear it whenever you like. Good for fire drills, "back in +5 minutes", shift changes, a quick "Welcome, visitors!", or any manual broadcast. + +It pushes a `web` overlay that renders a small dark card (optional coloured title band + +big message + a "posted" timestamp). The overlay page reads everything from its URL query +string, so there's no server-side state — the message lives entirely in the pushed URL. + +## How it works + +``` +announce.js ──POST /api/pip──▶ server ──WS device:pip-show──▶ player + renders