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