mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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>
This commit is contained in:
parent
5f83fc20d3
commit
0b138f10c6
3
Examples/PIP-Air-Quality/.gitignore
vendored
Normal file
3
Examples/PIP-Air-Quality/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
89
Examples/PIP-Air-Quality/README.md
Normal file
89
Examples/PIP-Air-Quality/README.md
Normal file
|
|
@ -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://<your-server>/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://<your-server>/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).
|
||||||
44
Examples/PIP-Air-Quality/aqi-overlay.html
Normal file
44
Examples/PIP-Air-Quality/aqi-overlay.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Air Quality</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45);
|
||||||
|
padding: 14px 18px; box-sizing: border-box; border-left: 8px solid #888; }
|
||||||
|
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||||
|
.loc { font-size: clamp(13px, 3.2vw, 18px); font-weight: 600; opacity: .92;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.badge { font-size: clamp(11px, 2.6vw, 15px); font-weight: 800; text-transform: uppercase;
|
||||||
|
letter-spacing: .03em; padding: 3px 9px; border-radius: 999px; color: #111; white-space: nowrap; }
|
||||||
|
.mid { display: flex; align-items: baseline; gap: 10px; margin-top: 2px; }
|
||||||
|
.aqi { font-size: clamp(40px, 13vw, 68px); font-weight: 800; line-height: 1; }
|
||||||
|
.aqilabel { font-size: clamp(11px, 2.6vw, 14px); font-weight: 700; opacity: .7; }
|
||||||
|
.cat { font-size: clamp(13px, 3.2vw, 18px); font-weight: 700; }
|
||||||
|
.grid { margin-top: auto; display: flex; flex-wrap: wrap; gap: 4px 16px;
|
||||||
|
font-size: clamp(11px, 2.4vw, 14px); opacity: .85; padding-top: 8px; }
|
||||||
|
.grid b { font-weight: 700; opacity: 1; }
|
||||||
|
.updated { opacity: .7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card" id="card">
|
||||||
|
<div class="top">
|
||||||
|
<span class="loc" id="loc"></span>
|
||||||
|
<span class="badge" id="badge"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mid">
|
||||||
|
<span class="aqi" id="aqi"></span>
|
||||||
|
<span class="aqilabel">US AQI</span>
|
||||||
|
</div>
|
||||||
|
<div class="cat" id="cat"></div>
|
||||||
|
<div class="grid" id="grid"></div>
|
||||||
|
<div class="grid"><span class="updated" id="updated"></span></div>
|
||||||
|
</div>
|
||||||
|
<script src="aqi-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
Examples/PIP-Air-Quality/aqi-overlay.js
Normal file
38
Examples/PIP-Air-Quality/aqi-overlay.js
Normal file
|
|
@ -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('<b>PM2.5</b> ' + esc(get('pm25')));
|
||||||
|
if (get('pm10') !== '') parts.push('<b>PM10</b> ' + esc(get('pm10')));
|
||||||
|
if (get('ozone') !== '') parts.push('<b>O₃</b> ' + esc(get('ozone')));
|
||||||
|
if (get('no2') !== '') parts.push('<b>NO₂</b> ' + 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];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
149
Examples/PIP-Air-Quality/aqi.js
Normal file
149
Examples/PIP-Air-Quality/aqi.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
16
Examples/PIP-Air-Quality/config.example.json
Normal file
16
Examples/PIP-Air-Quality/config.example.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
21
Examples/PIP-Air-Quality/fixture-aqi.json
Normal file
21
Examples/PIP-Air-Quality/fixture-aqi.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Examples/PIP-Air-Quality/package.json
Normal file
12
Examples/PIP-Air-Quality/package.json
Normal file
|
|
@ -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" }
|
||||||
|
}
|
||||||
58
Examples/PIP-Air-Quality/test.js
Normal file
58
Examples/PIP-Air-Quality/test.js
Normal file
|
|
@ -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);
|
||||||
3
Examples/PIP-Announce-Broadcast/.gitignore
vendored
Normal file
3
Examples/PIP-Announce-Broadcast/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
89
Examples/PIP-Announce-Broadcast/README.md
Normal file
89
Examples/PIP-Announce-Broadcast/README.md
Normal file
|
|
@ -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 <iframe
|
||||||
|
src=message-overlay.html?title&message&color>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `announce.js` builds an overlay URL from `overlay_base_url` + `?title&message&color` and
|
||||||
|
POSTs it to `/api/pip` (`type: "web"`).
|
||||||
|
- The player loads that URL in an iframe overlay. Because the player enforces a strict CSP
|
||||||
|
(`script-src 'self'`), the overlay HTML loads its JS via `<script src="message-overlay.js">`
|
||||||
|
(no inline scripts) and the JS reads the query string.
|
||||||
|
- `duration` controls auto-dismiss: `0` (default) keeps it up until you clear it; any
|
||||||
|
positive value (seconds) auto-clears on the player at that time.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
You need an `st_` API token with the **`full`** scope (PiP is fleet-affecting).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json: api_base, api_token, overlay_base_url, device_id
|
||||||
|
```
|
||||||
|
|
||||||
|
The overlay page is served by the signage server as a **same-origin** static file. Copy the
|
||||||
|
two overlay files into the server's frontend directory and point `overlay_base_url` at them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the repo root, into the served frontend dir:
|
||||||
|
cp Examples/PIP-Announce-Broadcast/message-overlay.html frontend/
|
||||||
|
cp Examples/PIP-Announce-Broadcast/message-overlay.js frontend/
|
||||||
|
# then in config.json: "overlay_base_url": "https://<your-server>/message-overlay.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
Same-origin matters: the player iframe and the overlay must share the server's origin so
|
||||||
|
the self-signed cert / CSP are honoured.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# basic broadcast (stays until cleared)
|
||||||
|
node announce.js "Fire drill at 2:00 PM"
|
||||||
|
|
||||||
|
# with a coloured title band, auto-clear after 60s, centered
|
||||||
|
node announce.js "Back in 5 minutes" --title "AT LUNCH" --duration 60 --color "#E8730C" --position center
|
||||||
|
|
||||||
|
# target a specific device or a group (overrides config device_id)
|
||||||
|
node announce.js "All-hands in the atrium" --group <GROUP_ID>
|
||||||
|
|
||||||
|
# clear it
|
||||||
|
node announce.js --clear --device <DEVICE_ID> --pip <PIP_ID>
|
||||||
|
# (omit --pip to clear whatever overlay is showing)
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags: `--title`, `--device`, `--group`, `--duration` (sec), `--color` (#RRGGBB),
|
||||||
|
`--position` (`top-right|top-left|bottom-right|bottom-left|center`), `--config`, `--clear`, `--pip`.
|
||||||
|
|
||||||
|
## Local quick-start (this dev box)
|
||||||
|
|
||||||
|
A web player is already running and paired:
|
||||||
|
|
||||||
|
- `api_base`: `https://localhost:3443/` (self-signed — prefix commands with `NODE_TLS_REJECT_UNAUTHORIZED=0`)
|
||||||
|
- `device_id`: `DEVICE_OR_GROUP_ID`
|
||||||
|
- token: `st_REPLACE_WITH_A_FULL_SCOPE_TOKEN`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp Examples/PIP-Announce-Broadcast/message-overlay.html frontend/
|
||||||
|
cp Examples/PIP-Announce-Broadcast/message-overlay.js frontend/
|
||||||
|
cd Examples/PIP-Announce-Broadcast
|
||||||
|
# config.json with the values above and overlay_base_url=https://localhost:3443/message-overlay.html
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node announce.js "Hello from PiP" --title TEST --duration 20
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # offline; exercises the URL builder and arg parser
|
||||||
|
```
|
||||||
141
Examples/PIP-Announce-Broadcast/announce.js
Normal file
141
Examples/PIP-Announce-Broadcast/announce.js
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// PIP-Announce-Broadcast — flash a one-off announcement onto a ScreenTinker screen
|
||||||
|
// or group via the PiP overlay API, then clear it on demand.
|
||||||
|
//
|
||||||
|
// node announce.js "Fire drill at 2:00 PM" [--title "NOTICE"]
|
||||||
|
// [--device <id> | --group <id>] [--duration 60] [--color "#CC0000"]
|
||||||
|
// [--position center] [--config config.json]
|
||||||
|
// node announce.js --clear [--device <id>] [--pip <pip_id>]
|
||||||
|
//
|
||||||
|
// 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) -------------------------
|
||||||
|
|
||||||
|
// Sanitise a colour to exactly 6 hex digits (no '#'); fall back to CC0000.
|
||||||
|
function sanitizeColor(c) {
|
||||||
|
const hex = String(c || '').replace(/[^0-9a-fA-F]/g, '');
|
||||||
|
return hex.length === 6 ? hex : 'CC0000';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the overlay iframe URL: overlay_base_url + ?title&message&color.
|
||||||
|
// Color is sanitised to 6 hex; everything is URL-encoded by URLSearchParams.
|
||||||
|
function buildOverlayUri(base, { title = '', message = '', color = '' } = {}) {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
title: title || '',
|
||||||
|
message: message || '',
|
||||||
|
color: sanitizeColor(color),
|
||||||
|
});
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal flag parser. First non-flag positional is the message.
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const out = { _: [] };
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--clear') out.clear = true;
|
||||||
|
else if (a.startsWith('--')) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith('--')) out[key] = true;
|
||||||
|
else { out[key] = next; i++; }
|
||||||
|
} else out._.push(a);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 = parseArgs(process.argv.slice(2));
|
||||||
|
const cfg = loadConfig(args.config);
|
||||||
|
|
||||||
|
const apiBase = String(cfg.api_base || '').replace(/\/$/, '');
|
||||||
|
const token = cfg.api_token;
|
||||||
|
const target = args.device || args.group || cfg.device_id;
|
||||||
|
|
||||||
|
if (!apiBase || !token) { console.error('config must set api_base and api_token.'); process.exit(1); }
|
||||||
|
if (!target) { console.error('no target: pass --device/--group or set device_id in config.'); process.exit(1); }
|
||||||
|
|
||||||
|
if (args.clear) {
|
||||||
|
const body = { device_id: target };
|
||||||
|
if (args.pip && args.pip !== true) body.pip_id = args.pip;
|
||||||
|
const { ok, status, json } = await postJson(`${apiBase}/api/pip/clear`, token, body);
|
||||||
|
if (!ok) { console.error(`clear failed (${status}): ${json.error || 'unknown'}`); process.exit(1); }
|
||||||
|
console.log(`cleared on ${target} — sent=${json.sent} offline=${json.offline}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = args._[0];
|
||||||
|
if (!message) {
|
||||||
|
console.error('usage: node announce.js "your message" [--title T] [--device ID|--group ID] [--duration N] [--color #RRGGBB] [--position P]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ov = cfg.overlay || {};
|
||||||
|
const position = args.position || ov.position || 'center';
|
||||||
|
if (!POSITIONS.includes(position)) { console.error(`invalid --position; use one of: ${POSITIONS.join(', ')}`); process.exit(1); }
|
||||||
|
|
||||||
|
const color = args.color || ov.color || '#CC0000';
|
||||||
|
const duration = args.duration != null ? Math.max(0, parseInt(args.duration, 10) || 0) : (ov.duration != null ? ov.duration : 0);
|
||||||
|
const overlayBase = cfg.overlay_base_url;
|
||||||
|
if (!overlayBase) { console.error('config must set overlay_base_url.'); process.exit(1); }
|
||||||
|
|
||||||
|
const uri = buildOverlayUri(overlayBase, {
|
||||||
|
title: (args.title && args.title !== true) ? args.title : (cfg.default_title || ''),
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
device_id: target,
|
||||||
|
type: 'web',
|
||||||
|
uri,
|
||||||
|
position,
|
||||||
|
width: ov.width || 900,
|
||||||
|
height: ov.height || 300,
|
||||||
|
duration,
|
||||||
|
border_radius: ov.border_radius != null ? ov.border_radius : 16,
|
||||||
|
opacity: ov.opacity != null ? ov.opacity : 1,
|
||||||
|
close_button: false,
|
||||||
|
title: (args.title && args.title !== true) ? args.title : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { ok, status, json } = await postJson(`${apiBase}/api/pip`, token, body);
|
||||||
|
if (!ok || !json.pip_id) { console.error(`show failed (${status}): ${json.error || 'unknown'}`); process.exit(1); }
|
||||||
|
console.log(`shown on ${target} (${json.target}) pip=${json.pip_id} dur=${duration || '∞'}s sent=${json.sent} offline=${json.offline}`);
|
||||||
|
console.log(`clear it with: node announce.js --clear --device ${target} --pip ${json.pip_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((e) => { console.error(e.message || e); process.exit(1); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildOverlayUri, sanitizeColor, parseArgs, POSITIONS };
|
||||||
17
Examples/PIP-Announce-Broadcast/config.example.json
Normal file
17
Examples/PIP-Announce-Broadcast/config.example.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/message-overlay.html",
|
||||||
|
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
"default_title": "NOTICE",
|
||||||
|
|
||||||
|
"overlay": {
|
||||||
|
"position": "center",
|
||||||
|
"width": 900,
|
||||||
|
"height": 300,
|
||||||
|
"border_radius": 16,
|
||||||
|
"color": "#CC0000",
|
||||||
|
"duration": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Examples/PIP-Announce-Broadcast/message-overlay.html
Normal file
30
Examples/PIP-Announce-Broadcast/message-overlay.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Announcement</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||||
|
.band { padding: 12px 22px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;
|
||||||
|
font-size: clamp(14px, 2.8vw, 22px); display: none; }
|
||||||
|
.band.show { display: block; }
|
||||||
|
.body { padding: 22px 26px; display: flex; flex-direction: column; gap: 12px; flex: 1; justify-content: center; }
|
||||||
|
.message { font-size: clamp(22px, 5.5vw, 44px); font-weight: 700; line-height: 1.18; }
|
||||||
|
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="band" id="band"></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="message" id="message"></div>
|
||||||
|
<div class="footer"><span id="updated"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="message-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
Examples/PIP-Announce-Broadcast/message-overlay.js
Normal file
23
Examples/PIP-Announce-Broadcast/message-overlay.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the announcement fields from the URL query string and populates the card.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||||
|
|
||||||
|
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||||
|
|
||||||
|
var title = get('title');
|
||||||
|
var band = document.getElementById('band');
|
||||||
|
if (title) {
|
||||||
|
band.textContent = title.toUpperCase();
|
||||||
|
band.style.background = color;
|
||||||
|
band.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('message').textContent = get('message') || 'Announcement';
|
||||||
|
|
||||||
|
// Footer shows when the overlay was rendered, so a static announcement still
|
||||||
|
// reads as "current".
|
||||||
|
var now = new Date();
|
||||||
|
document.getElementById('updated').textContent = isNaN(now) ? '' : ('posted ' + now.toLocaleString());
|
||||||
|
})();
|
||||||
12
Examples/PIP-Announce-Broadcast/package.json
Normal file
12
Examples/PIP-Announce-Broadcast/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-announce-broadcast",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: flash a one-off announcement onto a ScreenTinker screen or group via the PiP overlay API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "announce.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node announce.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
42
Examples/PIP-Announce-Broadcast/test.js
Normal file
42
Examples/PIP-Announce-Broadcast/test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline test for the pure overlay-URI builder. No network, no config needed.
|
||||||
|
const { buildOverlayUri, sanitizeColor, parseArgs } = require('./announce');
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
function check(name, cond) {
|
||||||
|
console.log(`${cond ? '✓' : '✗'} ${name}`);
|
||||||
|
if (!cond) ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// color sanitisation
|
||||||
|
check("sanitizeColor strips '#'", sanitizeColor('#CC0000') === 'CC0000');
|
||||||
|
check('sanitizeColor falls back on garbage', sanitizeColor('not-a-color') === 'CC0000');
|
||||||
|
check('sanitizeColor falls back on short hex', sanitizeColor('#FFF') === 'CC0000');
|
||||||
|
check('sanitizeColor keeps valid 6-hex', sanitizeColor('1a2b3c') === '1a2b3c');
|
||||||
|
|
||||||
|
// uri building + round-trip through URLSearchParams
|
||||||
|
const base = 'https://signage.example.com/message-overlay.html';
|
||||||
|
const msg = 'Fire drill at 2:00 PM — exit via Stairwell B & meet @ lot #3';
|
||||||
|
const uri = buildOverlayUri(base, { title: 'Notice!', message: msg, color: '#CC0000' });
|
||||||
|
const u = new URL(uri);
|
||||||
|
|
||||||
|
check('uri keeps the base path', u.pathname.endsWith('/message-overlay.html'));
|
||||||
|
check('message round-trips exactly', u.searchParams.get('message') === msg);
|
||||||
|
check('title round-trips', u.searchParams.get('title') === 'Notice!');
|
||||||
|
check('color is sanitised in the uri', u.searchParams.get('color') === 'CC0000');
|
||||||
|
check('special chars are encoded (no raw space/&/# in query string)',
|
||||||
|
!/[ #]/.test(u.search) && (u.search.match(/&/g) || []).length === 2);
|
||||||
|
|
||||||
|
// appends with '&' when the base already has a query
|
||||||
|
const uri2 = buildOverlayUri(base + '?v=2', { message: 'hi', color: 'abcdef' });
|
||||||
|
check("appends with '&' when base has a query", uri2.includes('?v=2&') && new URL(uri2).searchParams.get('message') === 'hi');
|
||||||
|
|
||||||
|
// arg parsing
|
||||||
|
const a = parseArgs(['Hello world', '--title', 'NOTICE', '--duration', '60', '--clear']);
|
||||||
|
check('parseArgs captures positional message', a._[0] === 'Hello world');
|
||||||
|
check('parseArgs reads flag values', a.title === 'NOTICE' && a.duration === '60');
|
||||||
|
check('parseArgs sets boolean --clear', a.clear === true);
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
3
Examples/PIP-CAP-AU-Alert-Monitor/.gitignore
vendored
Normal file
3
Examples/PIP-CAP-AU-Alert-Monitor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
92
Examples/PIP-CAP-AU-Alert-Monitor/README.md
Normal file
92
Examples/PIP-CAP-AU-Alert-Monitor/README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# CAP-AU → ScreenTinker PiP alert monitor (example)
|
||||||
|
|
||||||
|
Watches a CAP-AU emergency feed (default: the **NSW RFS `majorIncidentsCAP`** feed) and,
|
||||||
|
when a qualifying alert covers a screen's location, pushes a **PiP web overlay** to that
|
||||||
|
screen — then clears it when the alert expires, is cancelled, or leaves the feed.
|
||||||
|
|
||||||
|
It uses the **existing** ScreenTinker PiP API (`POST /api/pip`, `POST /api/pip/clear`).
|
||||||
|
No server changes required.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
CAP-AU feed ──poll──▶ parse (EDXL unwrap) ──▶ gate (AlertLevel + geofence) ──▶ POST /api/pip
|
||||||
|
◀─ clear on expiry/cancel/gone
|
||||||
|
```
|
||||||
|
|
||||||
|
Three non-obvious things this example gets right, learned from the real feed:
|
||||||
|
|
||||||
|
1. **It's EDXL-DE wrapped.** The feed is not a flat list of CAP alerts — each `<alert>`
|
||||||
|
is embedded under `EDXLDistribution > contentObject > xmlContent > embeddedXMLContent`.
|
||||||
|
`cap-parse.js` unwraps that.
|
||||||
|
2. **Gate on `AlertLevel`, not CAP `<severity>`.** RFS leaves `<severity>`/`<urgency>`
|
||||||
|
as `Unknown` for routine incidents. The real urgency lives in a `<parameter>` named
|
||||||
|
`AlertLevel` (`Planned Burn` / `Advice` / `Watch and Act` / `Emergency Warning`).
|
||||||
|
Default threshold shows only `Watch and Act` and `Emergency Warning`, so routine
|
||||||
|
hazard-reduction burns never hit a screen.
|
||||||
|
3. **CAP coordinates are `lat,lon`** — the reverse of GeoJSON's `lon,lat`. The geofence
|
||||||
|
keeps that flip in one place; feeding raw CAP coords into a `lon,lat` library is the
|
||||||
|
classic "matches on the wrong side of the planet" bug.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp config.example.json config.json # then edit it
|
||||||
|
```
|
||||||
|
|
||||||
|
In `config.json`:
|
||||||
|
|
||||||
|
- `api_base` — your ScreenTinker server URL.
|
||||||
|
- `api_token` — an **`st_` API token with the `full` scope** (PiP is fleet-affecting and
|
||||||
|
full-trust, so the route requires it). Create one in the dashboard's API-token section.
|
||||||
|
- `overlay_base_url` — where `alert-overlay.html` is hosted, **reachable by the player**
|
||||||
|
(the player fetches the overlay URL directly). Drop the file on the ScreenTinker host
|
||||||
|
or any static host.
|
||||||
|
- `screens` — each screen's `lat`/`lon` (its physical location, used for the geofence)
|
||||||
|
and the `device_id` (a device **or** group id) to push the overlay to.
|
||||||
|
- `alert_levels` — the AlertLevel threshold (default `["Watch and Act","Emergency Warning"]`).
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # uses ./config.json
|
||||||
|
# or
|
||||||
|
node monitor.js /path/to/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
On `Ctrl-C` it clears any overlays it put up, so a screen never keeps a stale alert.
|
||||||
|
|
||||||
|
## Test the parser (no server needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs the EDXL/gate/geofence logic against `fixture-feed.xml` (two real RFS planned burns
|
||||||
|
plus a synthetic Emergency Warning and a distant Watch-and-Act) and asserts that only the
|
||||||
|
in-area Emergency Warning would fire.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `monitor.js` | Poll loop + PiP show/clear lifecycle (dedup by CAP identifier). |
|
||||||
|
| `cap-parse.js` | EDXL unwrap, AlertLevel/field extraction, polygon+circle geofence, gate. |
|
||||||
|
| `alert-overlay.html` | The web overlay the PiP points at; renders from `?level=&headline=&area=…`. |
|
||||||
|
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||||
|
| `fixture-feed.xml` / `test-parse.js` | Offline test of the parser/gate. |
|
||||||
|
|
||||||
|
## Notes / next steps
|
||||||
|
|
||||||
|
- **Targeting model:** one screen → one `device_id` here. For many screens you'd likely
|
||||||
|
drive `screens` from your device inventory (each device's stored location) rather than
|
||||||
|
hand-listing them.
|
||||||
|
- **`msgType` Update:** currently an Update re-shows only if the identifier changed; if RFS
|
||||||
|
reuses an identifier on update you may want to force a re-push (clear + show) to refresh
|
||||||
|
the overlay content.
|
||||||
|
- **Other states/agencies:** point `feed_url` at other CAP-AU sources (state SES/fire
|
||||||
|
services). Field names in `<parameter>` are RFS-specific; other agencies differ, so the
|
||||||
|
`AlertLevel` mapping may need adjusting per source.
|
||||||
|
- This is an example/reference, not a life-safety system. Don't make it the only way people
|
||||||
|
are warned.
|
||||||
40
Examples/PIP-CAP-AU-Alert-Monitor/alert-overlay.html
Normal file
40
Examples/PIP-CAP-AU-Alert-Monitor/alert-overlay.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en-AU">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Emergency Alert</title>
|
||||||
|
<style>
|
||||||
|
/* Rendered inside the PiP box; transparent behind the card. Inline <style> is allowed
|
||||||
|
by the server CSP (styleSrc 'self' 'unsafe-inline'); the SCRIPT is external because
|
||||||
|
scriptSrc is 'self' with no 'unsafe-inline' — inline <script> would be blocked. */
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||||
|
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
|
||||||
|
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(18px, 4vw, 30px); }
|
||||||
|
.band .pulse { width: 16px; height: 16px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||||
|
animation: pulse 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||||
|
.body { padding: 18px 24px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
|
||||||
|
.headline { font-size: clamp(20px, 5vw, 38px); font-weight: 700; line-height: 1.15; }
|
||||||
|
.meta { font-size: clamp(13px, 2.6vw, 18px); color: #cfcfcf; display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
||||||
|
.meta b { color: #fff; font-weight: 600; }
|
||||||
|
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||||
|
.agency { opacity: .8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="band" id="band"><span class="pulse"></span><span id="level">ALERT</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="headline" id="headline"></div>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="footer"><span class="agency">NSW Rural Fire Service</span> <span id="updated"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- external, same-origin: satisfies scriptSrc 'self' -->
|
||||||
|
<script src="overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
183
Examples/PIP-CAP-AU-Alert-Monitor/cap-parse.js
Normal file
183
Examples/PIP-CAP-AU-Alert-Monitor/cap-parse.js
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
|
||||||
|
// wrap their alerts the same way). Three jobs:
|
||||||
|
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
|
||||||
|
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
|
||||||
|
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
|
||||||
|
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
|
||||||
|
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
|
||||||
|
// flip in one place so callers never have to think about it.
|
||||||
|
|
||||||
|
const { XMLParser } = require('fast-xml-parser');
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
|
||||||
|
parseTagValue: false, // keep everything as strings; we coerce deliberately
|
||||||
|
trimValues: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always work with arrays even when the XML has a single child.
|
||||||
|
function arr(x) {
|
||||||
|
if (x === undefined || x === null) return [];
|
||||||
|
return Array.isArray(x) ? x : [x];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
|
||||||
|
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
|
||||||
|
// these instead of regexing the HTML-encoded <description> blob.
|
||||||
|
function paramsToMap(info) {
|
||||||
|
const out = {};
|
||||||
|
for (const p of arr(info && info.parameter)) {
|
||||||
|
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
|
||||||
|
function parsePolygon(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const pts = String(str).trim().split(/\s+/).map((pair) => {
|
||||||
|
const [lat, lon] = pair.split(',').map(Number);
|
||||||
|
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
return pts.length >= 3 ? pts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
|
||||||
|
// which can never contain anything, so callers should treat a 0-radius circle as "no
|
||||||
|
// usable circle" and rely on the polygon.
|
||||||
|
function parseCircle(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const [center, radius] = String(str).trim().split(/\s+/);
|
||||||
|
const [lat, lon] = (center || '').split(',').map(Number);
|
||||||
|
const km = Number(radius);
|
||||||
|
if (![lat, lon, km].every(Number.isFinite)) return null;
|
||||||
|
return { lat, lon, km };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
|
||||||
|
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
|
||||||
|
function pointInPolygon(pt, poly) {
|
||||||
|
const x = pt.lon, y = pt.lat;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const xi = poly[i].lon, yi = poly[i].lat;
|
||||||
|
const xj = poly[j].lon, yj = poly[j].lat;
|
||||||
|
const intersect = (yi > y) !== (yj > y) &&
|
||||||
|
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineKm(a, b) {
|
||||||
|
const R = 6371;
|
||||||
|
const toRad = (d) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(b.lat - a.lat);
|
||||||
|
const dLon = toRad(b.lon - a.lon);
|
||||||
|
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
|
||||||
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
|
||||||
|
// circle. Returns false when the alert has no usable geometry.
|
||||||
|
function pointInAlertArea(point, alert) {
|
||||||
|
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
|
||||||
|
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten one embedded CAP <alert> into the shape the monitor works with.
|
||||||
|
function normaliseAlert(a) {
|
||||||
|
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
|
||||||
|
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
|
||||||
|
const params = paramsToMap(info);
|
||||||
|
return {
|
||||||
|
identifier: a.identifier != null ? String(a.identifier) : null,
|
||||||
|
msgType: a.msgType || null, // Alert | Update | Cancel
|
||||||
|
sent: a.sent || null,
|
||||||
|
headline: info.headline || params.IncidentName || '(no headline)',
|
||||||
|
event: info.event || null,
|
||||||
|
category: info.category || null,
|
||||||
|
responseType: info.responseType || null, // mostly "Monitor" in this feed
|
||||||
|
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
|
||||||
|
expires: info.expires || null,
|
||||||
|
web: info.web || null,
|
||||||
|
// RFS-specific, the field that actually carries urgency:
|
||||||
|
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
|
||||||
|
incidentType: params.IncidentType || null,
|
||||||
|
status: params.Status || null,
|
||||||
|
size: params.Fireground || params.Size || null,
|
||||||
|
council: params.CouncilArea || params.Location || null,
|
||||||
|
isFire: (params.IsFire || '').toLowerCase() === 'yes',
|
||||||
|
polygon: parsePolygon(area.polygon),
|
||||||
|
circle: parseCircle(area.circle),
|
||||||
|
areaDesc: area.areaDesc || null,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
|
||||||
|
function parseFeed(xml) {
|
||||||
|
const root = parser.parse(xml);
|
||||||
|
const dist = root.EDXLDistribution || root.Distribution || null;
|
||||||
|
const alerts = [];
|
||||||
|
if (dist) {
|
||||||
|
for (const co of arr(dist.contentObject)) {
|
||||||
|
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
|
||||||
|
for (const e of arr(embedded)) {
|
||||||
|
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: a bare CAP feed (no EDXL envelope).
|
||||||
|
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
|
||||||
|
}
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
|
||||||
|
function isExpired(alert, now = Date.now()) {
|
||||||
|
if (!alert.expires) return false;
|
||||||
|
const t = Date.parse(alert.expires);
|
||||||
|
return Number.isFinite(t) && t <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate: should this alert put something on a screen at `point`?
|
||||||
|
// - msgType must be Alert/Update (Cancel clears, never shows)
|
||||||
|
// - not expired
|
||||||
|
// - AlertLevel is at or above the configured threshold
|
||||||
|
// - the screen falls inside the alert area
|
||||||
|
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
|
||||||
|
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
|
||||||
|
|
||||||
|
function shouldShow(alert, point, opts = {}) {
|
||||||
|
const levels = opts.alertLevels || DEFAULT_LEVELS;
|
||||||
|
const now = opts.now || Date.now();
|
||||||
|
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
|
||||||
|
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
|
||||||
|
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
|
||||||
|
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
|
||||||
|
}
|
||||||
|
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
|
||||||
|
return { show: false, reason: 'no usable geometry' };
|
||||||
|
}
|
||||||
|
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
|
||||||
|
return { show: true, reason: 'in-area, at/above threshold' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseFeed,
|
||||||
|
normaliseAlert,
|
||||||
|
parsePolygon,
|
||||||
|
parseCircle,
|
||||||
|
pointInPolygon,
|
||||||
|
pointInAlertArea,
|
||||||
|
haversineKm,
|
||||||
|
isExpired,
|
||||||
|
shouldShow,
|
||||||
|
DEFAULT_LEVELS,
|
||||||
|
};
|
||||||
29
Examples/PIP-CAP-AU-Alert-Monitor/config.example.json
Normal file
29
Examples/PIP-CAP-AU-Alert-Monitor/config.example.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"feed_url": "https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml",
|
||||||
|
"poll_interval_sec": 120,
|
||||||
|
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
|
||||||
|
"overlay_base_url": "https://signage.example.com/alerts/alert-overlay.html",
|
||||||
|
|
||||||
|
"alert_levels": ["Watch and Act", "Emergency Warning"],
|
||||||
|
|
||||||
|
"screens": [
|
||||||
|
{ "name": "Foyer TV", "lat": -33.8688, "lon": 151.2093, "device_id": "DEVICE_OR_GROUP_ID_1" },
|
||||||
|
{ "name": "Cafe board", "lat": -33.7969, "lon": 151.2870, "device_id": "DEVICE_OR_GROUP_ID_2" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"overlay": {
|
||||||
|
"position": "center",
|
||||||
|
"width": 900,
|
||||||
|
"height": 320,
|
||||||
|
"opacity": 1,
|
||||||
|
"border_radius": 16,
|
||||||
|
"colors": {
|
||||||
|
"Emergency Warning": "CC0000",
|
||||||
|
"Watch and Act": "E8730C",
|
||||||
|
"Advice": "F2C200"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
Examples/PIP-CAP-AU-Alert-Monitor/fixture-feed.xml
Normal file
82
Examples/PIP-CAP-AU-Alert-Monitor/fixture-feed.xml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" standalone="no"?><?xml-stylesheet href="lib/RFS_EDXL_simple.xsl" type="text/xsl"?>
|
||||||
|
<EDXLDistribution xmlns="urn:oasis:names:tc:emergency:EDXL:DE:1.0">
|
||||||
|
<distributionID>RFSUniqueID:2026-06-18T00:00:00Z</distributionID>
|
||||||
|
<senderID>webmaster@rfs.nsw.gov.au</senderID>
|
||||||
|
<dateTimeSent>2026-06-18T10:00:00+10:00</dateTimeSent>
|
||||||
|
<distributionStatus>Actual</distributionStatus>
|
||||||
|
<distributionType>Report</distributionType>
|
||||||
|
<contentObject>
|
||||||
|
<contentDescription>Information on Aberdeen HR</contentDescription>
|
||||||
|
<xmlContent><embeddedXMLContent>
|
||||||
|
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||||
|
<identifier>2026-06-17T14:46:00.0000000:662900</identifier>
|
||||||
|
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-17T14:46:00+10:00</sent>
|
||||||
|
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||||
|
<info>
|
||||||
|
<category>Fire</category><event>Fire</event><responseType>Monitor</responseType>
|
||||||
|
<urgency>Unknown</urgency><severity>Unknown</severity><certainty>Observed</certainty>
|
||||||
|
<expires>2026-06-30T21:25:21+10:00</expires>
|
||||||
|
<headline>Aberdeen HR</headline>
|
||||||
|
<parameter><valueName>AlertLevel</valueName><value>Planned Burn</value></parameter>
|
||||||
|
<parameter><valueName>IncidentType</valueName><value>Hazard Reduction</value></parameter>
|
||||||
|
<parameter><valueName>Status</valueName><value>Under control</value></parameter>
|
||||||
|
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||||
|
<area>
|
||||||
|
<areaDesc>STANBOROUGH</areaDesc>
|
||||||
|
<polygon>-29.974,151.103 -29.984,151.103 -29.984,151.108 -29.974,151.108 -29.974,151.103</polygon>
|
||||||
|
<circle>-29.978,151.105 0</circle>
|
||||||
|
</area>
|
||||||
|
</info>
|
||||||
|
</alert>
|
||||||
|
</embeddedXMLContent></xmlContent>
|
||||||
|
</contentObject>
|
||||||
|
<contentObject>
|
||||||
|
<contentDescription>Emergency Warning - Test Ridge</contentDescription>
|
||||||
|
<xmlContent><embeddedXMLContent>
|
||||||
|
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||||
|
<identifier>2026-06-18T09:30:00.0000000:670001</identifier>
|
||||||
|
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:30:00+10:00</sent>
|
||||||
|
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||||
|
<info>
|
||||||
|
<category>Fire</category><event>Bushfire</event><responseType>Evacuate</responseType>
|
||||||
|
<urgency>Immediate</urgency><severity>Extreme</severity><certainty>Observed</certainty>
|
||||||
|
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||||
|
<headline>Test Ridge Road Fire</headline>
|
||||||
|
<parameter><valueName>AlertLevel</valueName><value>Emergency Warning</value></parameter>
|
||||||
|
<parameter><valueName>IncidentType</valueName><value>Bush Fire</value></parameter>
|
||||||
|
<parameter><valueName>Status</valueName><value>Out of control</value></parameter>
|
||||||
|
<parameter><valueName>CouncilArea</valueName><value>Testshire</value></parameter>
|
||||||
|
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||||
|
<area>
|
||||||
|
<areaDesc>Test Ridge - 5km around the screen</areaDesc>
|
||||||
|
<polygon>-33.90,151.10 -33.90,151.30 -33.80,151.30 -33.80,151.10 -33.90,151.10</polygon>
|
||||||
|
<circle>-33.85,151.20 8</circle>
|
||||||
|
</area>
|
||||||
|
</info>
|
||||||
|
</alert>
|
||||||
|
</embeddedXMLContent></xmlContent>
|
||||||
|
</contentObject>
|
||||||
|
<contentObject>
|
||||||
|
<contentDescription>Watch and Act - far away</contentDescription>
|
||||||
|
<xmlContent><embeddedXMLContent>
|
||||||
|
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||||
|
<identifier>2026-06-18T09:45:00.0000000:670002</identifier>
|
||||||
|
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:45:00+10:00</sent>
|
||||||
|
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||||
|
<info>
|
||||||
|
<category>Fire</category><event>Bushfire</event><responseType>Prepare</responseType>
|
||||||
|
<urgency>Expected</urgency><severity>Severe</severity><certainty>Likely</certainty>
|
||||||
|
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||||
|
<headline>Distant Valley Fire</headline>
|
||||||
|
<parameter><valueName>AlertLevel</valueName><value>Watch and Act</value></parameter>
|
||||||
|
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||||
|
<area>
|
||||||
|
<areaDesc>Distant Valley (far from screen)</areaDesc>
|
||||||
|
<polygon>-31.00,150.00 -31.00,150.10 -30.90,150.10 -30.90,150.00 -31.00,150.00</polygon>
|
||||||
|
<circle>-30.95,150.05 0</circle>
|
||||||
|
</area>
|
||||||
|
</info>
|
||||||
|
</alert>
|
||||||
|
</embeddedXMLContent></xmlContent>
|
||||||
|
</contentObject>
|
||||||
|
</EDXLDistribution>
|
||||||
175
Examples/PIP-CAP-AU-Alert-Monitor/monitor.js
Normal file
175
Examples/PIP-CAP-AU-Alert-Monitor/monitor.js
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CAP-AU -> ScreenTinker PiP monitor.
|
||||||
|
//
|
||||||
|
// Polls a CAP-AU feed (default: the NSW RFS majorIncidentsCAP feed), and for each
|
||||||
|
// configured screen, pushes a PiP web overlay when a qualifying alert covers that
|
||||||
|
// screen's location — then clears it when the alert expires, is cancelled, or drops
|
||||||
|
// out of the feed. It talks to the EXISTING ScreenTinker PiP API (POST /api/pip and
|
||||||
|
// POST /api/pip/clear); it adds no server code.
|
||||||
|
//
|
||||||
|
// node monitor.js [path/to/config.json]
|
||||||
|
//
|
||||||
|
// Requires Node 18+ (uses global fetch). The config needs an st_ API token with the
|
||||||
|
// 'full' scope (PiP is fleet-affecting and full-trust, so the route demands it).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const cap = require('./cap-parse');
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
console.error('Copy config.example.json to config.json and fill it in.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEED_URL = cfg.feed_url || 'https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml';
|
||||||
|
const POLL_SEC = cfg.poll_interval_sec || 120; // RFS refreshes ~every 30 min; 2 min poll is plenty
|
||||||
|
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||||
|
const API_TOKEN = cfg.api_token;
|
||||||
|
const OVERLAY_BASE = cfg.overlay_base_url; // where alert-overlay.html is hosted, reachable BY THE PLAYER
|
||||||
|
const SCREENS = cfg.screens || []; // [{ name, lat, lon, device_id }]
|
||||||
|
const ALERT_LEVELS = cfg.alert_levels || cap.DEFAULT_LEVELS;
|
||||||
|
const OVERLAY = cfg.overlay || {};
|
||||||
|
|
||||||
|
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || SCREENS.length === 0) {
|
||||||
|
console.error('config must set api_base, api_token, overlay_base_url, and at least one screen.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// active overlays: key `${device_id}|${identifier}` -> { pip_id, expiresAt }
|
||||||
|
const active = new Map();
|
||||||
|
const keyFor = (deviceId, identifier) => `${deviceId}|${identifier}`;
|
||||||
|
|
||||||
|
// Colour the overlay by alert level (overridable in config.overlay.colors).
|
||||||
|
const LEVEL_COLORS = Object.assign(
|
||||||
|
{ 'Emergency Warning': 'CC0000', 'Watch and Act': 'E8730C', 'Advice': 'F2C200' },
|
||||||
|
OVERLAY.colors || {},
|
||||||
|
);
|
||||||
|
|
||||||
|
function overlayUri(alert) {
|
||||||
|
const color = LEVEL_COLORS[alert.alertLevel] || 'CC0000';
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
level: alert.alertLevel || '',
|
||||||
|
headline: alert.headline || '',
|
||||||
|
area: alert.areaDesc || alert.council || '',
|
||||||
|
status: alert.status || '',
|
||||||
|
updated: alert.sent || '',
|
||||||
|
color: color,
|
||||||
|
more: alert.web || '',
|
||||||
|
});
|
||||||
|
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipShow(deviceId, alert) {
|
||||||
|
const body = {
|
||||||
|
device_id: deviceId,
|
||||||
|
type: 'web',
|
||||||
|
uri: overlayUri(alert),
|
||||||
|
position: OVERLAY.position || 'center',
|
||||||
|
width: OVERLAY.width || 900,
|
||||||
|
height: OVERLAY.height || 320,
|
||||||
|
duration: 0, // 0 = until we explicitly clear it
|
||||||
|
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||||
|
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||||
|
close_button: false,
|
||||||
|
title: alert.alertLevel || 'Alert',
|
||||||
|
};
|
||||||
|
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(deviceId, 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: deviceId, pip_id: pipId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
let alerts;
|
||||||
|
try {
|
||||||
|
const res = await fetch(FEED_URL, { headers: { Accept: 'application/xml, text/xml' } });
|
||||||
|
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
|
||||||
|
alerts = cap.parseFeed(await res.text());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] feed fetch/parse error: ${e.message}`);
|
||||||
|
return; // keep the last state; try again next tick
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const stillQualifying = new Set(); // keys that should remain shown this tick
|
||||||
|
|
||||||
|
for (const screen of SCREENS) {
|
||||||
|
const point = { lat: screen.lat, lon: screen.lon };
|
||||||
|
for (const alert of alerts) {
|
||||||
|
if (!alert.identifier) continue;
|
||||||
|
const decision = cap.shouldShow(alert, point, { alertLevels: ALERT_LEVELS, now });
|
||||||
|
const key = keyFor(screen.device_id, alert.identifier);
|
||||||
|
if (!decision.show) continue;
|
||||||
|
stillQualifying.add(key);
|
||||||
|
if (active.has(key)) continue; // already on screen
|
||||||
|
try {
|
||||||
|
const pipId = await pipShow(screen.device_id, alert);
|
||||||
|
active.set(key, { pip_id: pipId, expiresAt: Date.parse(alert.expires) || null });
|
||||||
|
console.log(`[${new Date().toISOString()}] SHOW "${alert.headline}" (${alert.alertLevel}) on ${screen.name} [${screen.device_id}] pip=${pipId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] show error on ${screen.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear anything active that no longer qualifies (gone from feed, cancelled, expired,
|
||||||
|
// dropped below threshold, or moved out of area).
|
||||||
|
for (const [key, rec] of [...active.entries()]) {
|
||||||
|
if (stillQualifying.has(key)) continue;
|
||||||
|
const [deviceId] = key.split('|');
|
||||||
|
try {
|
||||||
|
await pipClear(deviceId, rec.pip_id);
|
||||||
|
active.delete(key);
|
||||||
|
console.log(`[${new Date().toISOString()}] CLEAR pip=${rec.pip_id} on ${deviceId} (no longer qualifying)`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] clear error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`CAP-AU PiP monitor starting`);
|
||||||
|
console.log(` feed: ${FEED_URL}`);
|
||||||
|
console.log(` poll: every ${POLL_SEC}s`);
|
||||||
|
console.log(` levels: ${ALERT_LEVELS.join(', ')}`);
|
||||||
|
console.log(` screens: ${SCREENS.map(s => `${s.name}(${s.lat},${s.lon})`).join(', ')}`);
|
||||||
|
await tick();
|
||||||
|
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||||
|
|
||||||
|
// On shutdown, clear everything we put up so screens don't keep a stale alert.
|
||||||
|
async function shutdown() {
|
||||||
|
clearInterval(timer);
|
||||||
|
console.log('\nclearing active overlays before exit...');
|
||||||
|
for (const [key, rec] of active.entries()) {
|
||||||
|
const [deviceId] = key.split('|');
|
||||||
|
try { await pipClear(deviceId, rec.pip_id); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
28
Examples/PIP-CAP-AU-Alert-Monitor/overlay.js
Normal file
28
Examples/PIP-CAP-AU-Alert-Monitor/overlay.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the alert fields from the URL query string and populates the card.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||||
|
|
||||||
|
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||||
|
document.getElementById('band').style.background = color;
|
||||||
|
document.getElementById('level').textContent = (get('level') || 'Alert').toUpperCase();
|
||||||
|
document.getElementById('headline').textContent = get('headline') || 'Emergency alert in your area';
|
||||||
|
|
||||||
|
var meta = [];
|
||||||
|
if (get('area')) meta.push('<b>Area:</b> ' + escapeHtml(get('area')));
|
||||||
|
if (get('status')) meta.push('<b>Status:</b> ' + escapeHtml(get('status')));
|
||||||
|
document.getElementById('meta').innerHTML = meta.join('');
|
||||||
|
|
||||||
|
var updated = get('updated');
|
||||||
|
if (updated) {
|
||||||
|
var d = new Date(updated);
|
||||||
|
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleString('en-AU'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
15
Examples/PIP-CAP-AU-Alert-Monitor/package.json
Normal file
15
Examples/PIP-CAP-AU-Alert-Monitor/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "cap-alert-monitor",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: monitor a CAP-AU feed and push emergency alerts to ScreenTinker screens via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "monitor.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node monitor.js",
|
||||||
|
"test": "node test-parse.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" },
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "^4.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Examples/PIP-CAP-AU-Alert-Monitor/test-parse.js
Normal file
43
Examples/PIP-CAP-AU-Alert-Monitor/test-parse.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const cap = require('./cap-parse');
|
||||||
|
|
||||||
|
const xml = fs.readFileSync('./fixture-feed.xml', 'utf8');
|
||||||
|
const alerts = cap.parseFeed(xml);
|
||||||
|
|
||||||
|
// A screen physically located inside the Emergency Warning area.
|
||||||
|
const SCREEN = { lat: -33.85, lon: 151.20 };
|
||||||
|
const now = Date.parse('2026-06-18T10:00:00+10:00');
|
||||||
|
|
||||||
|
console.log(`Parsed ${alerts.length} alert(s) from the EDXL envelope:\n`);
|
||||||
|
for (const a of alerts) {
|
||||||
|
const g = cap.shouldShow(a, SCREEN, { now });
|
||||||
|
console.log(`• ${a.headline}`);
|
||||||
|
console.log(` alertLevel=${a.alertLevel} severity(CAP)=${a.severity} msgType=${a.msgType}`);
|
||||||
|
console.log(` geometry: polygon=${a.polygon ? a.polygon.length + 'pts' : 'none'} circle=${a.circle ? a.circle.km + 'km' : 'none'}`);
|
||||||
|
console.log(` => ${g.show ? 'SHOW PiP' : 'skip'} (${g.reason})\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
const byLevel = Object.fromEntries(alerts.map(a => [a.alertLevel, a]));
|
||||||
|
const results = alerts.map(a => ({ h: a.headline, show: cap.shouldShow(a, SCREEN, { now }).show }));
|
||||||
|
const shown = results.filter(r => r.show).map(r => r.h);
|
||||||
|
|
||||||
|
const expectShown = ['Test Ridge Road Fire'];
|
||||||
|
const ok =
|
||||||
|
shown.length === 1 &&
|
||||||
|
shown[0] === 'Test Ridge Road Fire' &&
|
||||||
|
cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason.includes('below threshold') &&
|
||||||
|
cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason === 'outside area';
|
||||||
|
|
||||||
|
console.log('--- assertions ---');
|
||||||
|
console.log('only the in-area Emergency Warning shows:', shown.join(', ') || '(none)');
|
||||||
|
console.log('planned burn filtered by threshold:', cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason);
|
||||||
|
console.log('distant watch-and-act filtered by geofence:', cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason);
|
||||||
|
|
||||||
|
// lat/lon flip sanity: the screen point must NOT be found if we naively swap to lon,lat
|
||||||
|
const swapped = { lat: SCREEN.lon, lon: SCREEN.lat };
|
||||||
|
const ew = byLevel['Emergency Warning'];
|
||||||
|
console.log('flip guard (swapped coords should be OUTSIDE):', cap.pointInAlertArea(swapped, ew) ? 'FAIL (matched)' : 'ok (no match)');
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
3
Examples/PIP-Crypto-Ticker/.gitignore
vendored
Normal file
3
Examples/PIP-Crypto-Ticker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
112
Examples/PIP-Crypto-Ticker/README.md
Normal file
112
Examples/PIP-Crypto-Ticker/README.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# PiP Crypto Ticker
|
||||||
|
|
||||||
|
A live cryptocurrency **price ticker** for ScreenTinker screens. Polls
|
||||||
|
[CoinGecko](https://www.coingecko.com/en/api)'s keyless `simple/price` endpoint and
|
||||||
|
pushes a wide ticker-strip overlay via the **PiP API**. Each poll refreshes the same
|
||||||
|
overlay in place; prices update without a flash.
|
||||||
|
|
||||||
|
No API key required. Zero runtime dependencies (Node 18+ global `fetch`).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BTC $64,012.34 ▲ +1.23% • ETH $3,380.10 ▼ -0.46% • SOL … │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. `ticker.js` fetches `GET /api/v3/simple/price?ids=…&vs_currencies=…&include_24hr_change=true`.
|
||||||
|
2. It normalises the response into ordered items and encodes them compactly into the
|
||||||
|
overlay URL's query string (`items=BTC:64012.34:+1.23,…`).
|
||||||
|
3. It pushes a `type: "web"` PiP overlay (`duration: 0`, i.e. persistent) pointing at
|
||||||
|
`ticker-overlay.html`, which renders the strip. Up = green ▲, down = red ▼, flat = grey.
|
||||||
|
4. On the next poll it pushes again — the player keeps a single overlay slot
|
||||||
|
(last-show-wins), so the numbers refresh in place.
|
||||||
|
5. `Ctrl-C` (SIGINT) clears the overlay.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| file | purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `ticker.js` | poller + PiP pusher (and the pure, exported normaliser/encoder) |
|
||||||
|
| `ticker-overlay.html` / `ticker-overlay.js` | the overlay page (served by the signage server) |
|
||||||
|
| `config.example.json` | copy to `config.json` and fill in |
|
||||||
|
| `fixture-prices.json` | a saved CoinGecko response for the offline test |
|
||||||
|
| `test.js` | offline test — no network, no PiP push |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
The overlay page must be served **same-origin** with the signage server (the player
|
||||||
|
loads it in an iframe, and the server CSP only allows same-origin scripts). Copy the
|
||||||
|
two overlay files into the server's static frontend directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp ticker-overlay.html ticker-overlay.js /path/to/screentinker/frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then they're reachable at `https://<your-server>/ticker-overlay.html`.
|
||||||
|
|
||||||
|
Create a **full-scope** `st_` API token in the dashboard (Settings → API tokens), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json: api_base, api_token, overlay_base_url, device_id, coins
|
||||||
|
node ticker.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`device_id` may be a single device **or** a device group id.
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
| key | meaning |
|
||||||
|
|-----|---------|
|
||||||
|
| `api_base` | signage server base URL |
|
||||||
|
| `api_token` | full-scope `st_` token |
|
||||||
|
| `overlay_base_url` | URL of the served `ticker-overlay.html` |
|
||||||
|
| `device_id` | target device or group id |
|
||||||
|
| `vs_currency` | `usd`, `eur`, `gbp`, … |
|
||||||
|
| `coins` | array of `{ id, symbol }` — `id` is the CoinGecko id |
|
||||||
|
| `poll_interval_sec` | refresh cadence (default 120; respect CoinGecko rate limits) |
|
||||||
|
| `position` | `bottom-right` (default), `top-left`, … |
|
||||||
|
| `width` / `height` | overlay box px (default 1100×110) |
|
||||||
|
|
||||||
|
## Local quick-start (this machine)
|
||||||
|
|
||||||
|
A local ScreenTinker instance is already running on `https://localhost:3443` with a
|
||||||
|
paired web player (device `DEVICE_OR_GROUP_ID`). It uses a self-signed
|
||||||
|
cert, so set `NODE_TLS_REJECT_UNAUTHORIZED=0`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. serve the overlay assets from the local frontend dir
|
||||||
|
cp ticker-overlay.html ticker-overlay.js /home/owner/Downloads/remote_display/frontend/
|
||||||
|
|
||||||
|
# 2. config.json
|
||||||
|
cat > config.json <<'JSON'
|
||||||
|
{
|
||||||
|
"api_base": "https://localhost:3443/",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://localhost:3443/ticker-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
"vs_currency": "usd",
|
||||||
|
"coins": [
|
||||||
|
{ "id": "bitcoin", "symbol": "BTC" },
|
||||||
|
{ "id": "ethereum", "symbol": "ETH" },
|
||||||
|
{ "id": "solana", "symbol": "SOL" }
|
||||||
|
],
|
||||||
|
"poll_interval_sec": 120,
|
||||||
|
"position": "bottom-right"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
# 3. run
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node ticker.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test (offline)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Validates price/percent formatting, up/down/flat direction, and that the compact
|
||||||
|
`items` encoding round-trips through the overlay's decoder. Prints `RESULT: PASS ✅`.
|
||||||
21
Examples/PIP-Crypto-Ticker/config.example.json
Normal file
21
Examples/PIP-Crypto-Ticker/config.example.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/ticker-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"vs_currency": "usd",
|
||||||
|
"coins": [
|
||||||
|
{ "id": "bitcoin", "symbol": "BTC" },
|
||||||
|
{ "id": "ethereum", "symbol": "ETH" },
|
||||||
|
{ "id": "solana", "symbol": "SOL" },
|
||||||
|
{ "id": "cardano", "symbol": "ADA" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"poll_interval_sec": 120,
|
||||||
|
"position": "bottom-right",
|
||||||
|
"width": 1100,
|
||||||
|
"height": 110,
|
||||||
|
"border_radius": 14,
|
||||||
|
"opacity": 1
|
||||||
|
}
|
||||||
6
Examples/PIP-Crypto-Ticker/fixture-prices.json
Normal file
6
Examples/PIP-Crypto-Ticker/fixture-prices.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"bitcoin": { "usd": 64012.34, "usd_24h_change": 1.2345 },
|
||||||
|
"ethereum": { "usd": 3380.1, "usd_24h_change": -0.4567 },
|
||||||
|
"solana": { "usd": 152.4, "usd_24h_change": 0.002 },
|
||||||
|
"cardano": { "usd": 0.3821, "usd_24h_change": -2.8 }
|
||||||
|
}
|
||||||
12
Examples/PIP-Crypto-Ticker/package.json
Normal file
12
Examples/PIP-Crypto-Ticker/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-crypto-ticker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: poll CoinGecko (keyless) and push a live crypto price ticker to ScreenTinker screens via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "ticker.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node ticker.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
62
Examples/PIP-Crypto-Ticker/test.js
Normal file
62
Examples/PIP-Crypto-Ticker/test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline test: no network, no PiP push. Proves the normaliser formats prices and
|
||||||
|
// changes, derives direction from the 24h change, and that the compact items
|
||||||
|
// encoding round-trips through the overlay's decoder.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const t = require('./ticker');
|
||||||
|
|
||||||
|
const raw = JSON.parse(fs.readFileSync('./fixture-prices.json', 'utf8'));
|
||||||
|
const coins = [
|
||||||
|
{ id: 'bitcoin', symbol: 'BTC' },
|
||||||
|
{ id: 'ethereum', symbol: 'ETH' },
|
||||||
|
{ id: 'solana', symbol: 'SOL' },
|
||||||
|
{ id: 'cardano', symbol: 'ADA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const items = t.normalise(raw, { coins, vs_currency: 'usd' });
|
||||||
|
|
||||||
|
console.log('Normalised ticker items:\n');
|
||||||
|
for (const i of items) {
|
||||||
|
console.log(`• ${i.symbol} ${i.priceStr} ${i.changeStr} (${i.dir})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = t.encodeItems(items);
|
||||||
|
const decoded = t.decodeItems(encoded);
|
||||||
|
console.log(`\nencoded: ${encoded}\n`);
|
||||||
|
|
||||||
|
function eq(a, b, msg) { if (a !== b) { console.error(` ✗ ${msg}: got ${JSON.stringify(a)} want ${JSON.stringify(b)}`); return false; } return true; }
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
// order + count preserved
|
||||||
|
ok = eq(items.length, 4, 'item count') && ok;
|
||||||
|
ok = eq(items.map(i => i.symbol).join(','), 'BTC,ETH,SOL,ADA', 'symbol order') && ok;
|
||||||
|
|
||||||
|
// formatting: thousands separators, decimal precision by magnitude
|
||||||
|
ok = eq(items[0].priceStr, '64,012.34', 'BTC thousands+2dp') && ok;
|
||||||
|
ok = eq(items[0].changeStr, '+1.23%', 'BTC change sign') && ok;
|
||||||
|
ok = eq(items[0].dir, 'up', 'BTC dir') && ok;
|
||||||
|
|
||||||
|
ok = eq(items[1].priceStr, '3,380.10', 'ETH trailing zero') && ok;
|
||||||
|
ok = eq(items[1].dir, 'down', 'ETH dir (negative)') && ok;
|
||||||
|
|
||||||
|
// near-zero change rounds to flat
|
||||||
|
ok = eq(items[2].changeStr, '+0.00%', 'SOL ~0 change') && ok;
|
||||||
|
ok = eq(items[2].dir, 'flat', 'SOL dir flat') && ok;
|
||||||
|
|
||||||
|
// sub-$1 coin gets extra decimals, no thousands grouping
|
||||||
|
ok = eq(items[3].priceStr, '0.3821', 'ADA 4dp sub-dollar') && ok;
|
||||||
|
ok = eq(items[3].dir, 'down', 'ADA dir') && ok;
|
||||||
|
|
||||||
|
// round-trip: decoded display fields match the normaliser's
|
||||||
|
ok = eq(decoded.length, items.length, 'decoded count') && ok;
|
||||||
|
for (let k = 0; k < items.length; k++) {
|
||||||
|
ok = eq(decoded[k].symbol, items[k].symbol, `rt[${k}] symbol`) && ok;
|
||||||
|
ok = eq(decoded[k].priceStr, items[k].priceStr, `rt[${k}] priceStr`) && ok;
|
||||||
|
ok = eq(decoded[k].changeStr, items[k].changeStr, `rt[${k}] changeStr`) && ok;
|
||||||
|
ok = eq(decoded[k].dir, items[k].dir, `rt[${k}] dir`) && ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
31
Examples/PIP-Crypto-Ticker/ticker-overlay.html
Normal file
31
Examples/PIP-Crypto-Ticker/ticker-overlay.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Crypto Ticker</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
display: flex; align-items: stretch; }
|
||||||
|
.strip { flex: 1; display: flex; align-items: center; gap: 10px;
|
||||||
|
background: #14161a; color: #fff; border-radius: 14px; overflow: hidden;
|
||||||
|
padding: 0 16px; box-shadow: 0 8px 28px rgba(0,0,0,.45); }
|
||||||
|
.row { display: flex; align-items: center; gap: clamp(14px, 3vw, 34px);
|
||||||
|
overflow: hidden; white-space: nowrap; width: 100%; }
|
||||||
|
.chip { display: inline-flex; align-items: baseline; gap: 8px; }
|
||||||
|
.sym { font-weight: 800; letter-spacing: .04em; font-size: clamp(15px, 3.2vw, 26px); }
|
||||||
|
.price { font-weight: 600; font-variant-numeric: tabular-nums; font-size: clamp(15px, 3.2vw, 26px); }
|
||||||
|
.chg { font-weight: 700; font-variant-numeric: tabular-nums; font-size: clamp(13px, 2.6vw, 20px); }
|
||||||
|
.up { color: #2ecc71; }
|
||||||
|
.down { color: #ff5b5b; }
|
||||||
|
.flat { color: #9aa0a6; }
|
||||||
|
.dot { color: #3a3f47; }
|
||||||
|
.empty { color: #9aa0a6; font-size: clamp(14px, 3vw, 22px); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="strip"><div class="row" id="row"></div></div>
|
||||||
|
<script src="ticker-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
Examples/PIP-Crypto-Ticker/ticker-overlay.js
Normal file
62
Examples/PIP-Crypto-Ticker/ticker-overlay.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Parses the compact `items` query (SYMBOL:rawprice:signedchange, comma-joined) and
|
||||||
|
// renders a horizontal ticker strip. Mirrors decodeItems() in ticker.js.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var items = (q.get('items') || '').trim();
|
||||||
|
var cur = (q.get('cur') || 'usd').toLowerCase();
|
||||||
|
var CUR = { usd: '$', eur: '€', gbp: '£', jpy: '¥', aud: 'A$', cad: 'C$' };
|
||||||
|
var sym = CUR[cur] || '';
|
||||||
|
|
||||||
|
function addThousands(numStr) {
|
||||||
|
var neg = numStr.charAt(0) === '-';
|
||||||
|
var s = neg ? numStr.slice(1) : numStr;
|
||||||
|
var parts = s.split('.');
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
return (neg ? '-' : '') + parts.join('.');
|
||||||
|
}
|
||||||
|
function dirOf(chg) {
|
||||||
|
var r = Number(parseFloat(chg).toFixed(2));
|
||||||
|
return r > 0 ? 'up' : (r < 0 ? 'down' : 'flat');
|
||||||
|
}
|
||||||
|
function arrow(dir) { return dir === 'up' ? '▲' : dir === 'down' ? '▼' : '■'; }
|
||||||
|
|
||||||
|
var row = document.getElementById('row');
|
||||||
|
var toks = items ? items.split(',').filter(Boolean) : [];
|
||||||
|
if (toks.length === 0) {
|
||||||
|
var e = document.createElement('span');
|
||||||
|
e.className = 'empty';
|
||||||
|
e.textContent = 'No market data';
|
||||||
|
row.appendChild(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toks.forEach(function (tok, idx) {
|
||||||
|
var p = tok.split(':');
|
||||||
|
var symbol = p[0] || '';
|
||||||
|
var priceRaw = p[1] || '0';
|
||||||
|
var chg = p[2] || '+0.00';
|
||||||
|
var dir = dirOf(chg);
|
||||||
|
|
||||||
|
var chip = document.createElement('span');
|
||||||
|
chip.className = 'chip';
|
||||||
|
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.className = 'sym'; s.textContent = symbol;
|
||||||
|
|
||||||
|
var pr = document.createElement('span');
|
||||||
|
pr.className = 'price'; pr.textContent = sym + addThousands(priceRaw);
|
||||||
|
|
||||||
|
var c = document.createElement('span');
|
||||||
|
c.className = 'chg ' + dir; c.textContent = arrow(dir) + ' ' + chg + '%';
|
||||||
|
|
||||||
|
chip.appendChild(s); chip.appendChild(pr); chip.appendChild(c);
|
||||||
|
row.appendChild(chip);
|
||||||
|
|
||||||
|
if (idx < toks.length - 1) {
|
||||||
|
var dot = document.createElement('span');
|
||||||
|
dot.className = 'dot'; dot.textContent = '•';
|
||||||
|
row.appendChild(dot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
209
Examples/PIP-Crypto-Ticker/ticker.js
Normal file
209
Examples/PIP-Crypto-Ticker/ticker.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Crypto price ticker -> ScreenTinker PiP overlay.
|
||||||
|
//
|
||||||
|
// Polls CoinGecko's keyless simple/price endpoint and pushes a wide "ticker strip"
|
||||||
|
// web overlay to a device or group. Each poll refreshes the same overlay slot
|
||||||
|
// (last-show-wins on the player), so prices update in place. The overlay is
|
||||||
|
// persistent (duration 0) and is cleared on SIGINT/SIGTERM.
|
||||||
|
//
|
||||||
|
// node ticker.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');
|
||||||
|
|
||||||
|
// ---- pure helpers (exported for offline tests) --------------------------------
|
||||||
|
|
||||||
|
const CUR_SYMBOL = { usd: '$', eur: '€', gbp: '£', jpy: '¥', aud: 'A$', cad: 'C$' };
|
||||||
|
|
||||||
|
// Decimals scale with magnitude: cheap coins need more precision than BTC.
|
||||||
|
function priceDecimals(p) {
|
||||||
|
const a = Math.abs(Number(p) || 0);
|
||||||
|
if (a >= 1) return 2;
|
||||||
|
if (a >= 0.01) return 4;
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group the integer part with thousands separators; keep the fractional part as-is.
|
||||||
|
function addThousands(numStr) {
|
||||||
|
const neg = numStr.startsWith('-');
|
||||||
|
const s = neg ? numStr.slice(1) : numStr;
|
||||||
|
const [int, frac] = s.split('.');
|
||||||
|
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
return (neg ? '-' : '') + grouped + (frac != null ? '.' + frac : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw (delimiter-safe) numeric price string: fixed decimals, NO thousands commas.
|
||||||
|
function priceRaw(p) { return (Number(p) || 0).toFixed(priceDecimals(p)); }
|
||||||
|
|
||||||
|
// Display price string: thousands-separated.
|
||||||
|
function formatPrice(p) { return addThousands(priceRaw(p)); }
|
||||||
|
|
||||||
|
// Signed change, 2 decimals, no % (compact for the query). e.g. "+1.23", "-0.45".
|
||||||
|
function signedChange(c) {
|
||||||
|
const n = Number(c) || 0;
|
||||||
|
return (n >= 0 ? '+' : '') + n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display change with % suffix. e.g. "+1.23%".
|
||||||
|
function formatChange(c) { return signedChange(c) + '%'; }
|
||||||
|
|
||||||
|
// Direction from the rounded 2-decimal change, so it matches what's displayed.
|
||||||
|
function dirOf(c) {
|
||||||
|
const r = Number((Number(c) || 0).toFixed(2));
|
||||||
|
if (r > 0) return 'up';
|
||||||
|
if (r < 0) return 'down';
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CoinGecko simple/price response -> normalised items, preserving config coin order.
|
||||||
|
// raw[coinId][vs] = price ; raw[coinId][vs+"_24h_change"] = pct change
|
||||||
|
function normalise(raw, opts = {}) {
|
||||||
|
const vs = (opts.vs_currency || 'usd').toLowerCase();
|
||||||
|
const coins = opts.coins || [];
|
||||||
|
const out = [];
|
||||||
|
for (const coin of coins) {
|
||||||
|
const entry = raw && raw[coin.id];
|
||||||
|
if (!entry || entry[vs] == null) continue;
|
||||||
|
const price = Number(entry[vs]);
|
||||||
|
const change = Number(entry[`${vs}_24h_change`]) || 0;
|
||||||
|
out.push({
|
||||||
|
symbol: coin.symbol || coin.id.toUpperCase(),
|
||||||
|
price,
|
||||||
|
priceStr: formatPrice(price),
|
||||||
|
change24h: change,
|
||||||
|
changeStr: formatChange(change),
|
||||||
|
dir: dirOf(change),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact, comma/colon-delimited encoding for the overlay query string.
|
||||||
|
// "BTC:64012.34:+1.23,ETH:3380.10:-0.45"
|
||||||
|
function encodeItems(items) {
|
||||||
|
return items.map(i => `${i.symbol}:${priceRaw(i.price)}:${signedChange(i.change24h)}`).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse of encodeItems — mirrors the parser in ticker-overlay.js. Returns the
|
||||||
|
// display-ready shape so a test can prove the round-trip survives.
|
||||||
|
function decodeItems(s) {
|
||||||
|
if (!s) return [];
|
||||||
|
return s.split(',').filter(Boolean).map(tok => {
|
||||||
|
const [symbol, priceRawStr, chg] = tok.split(':');
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
priceStr: addThousands(priceRawStr),
|
||||||
|
changeStr: chg + '%',
|
||||||
|
dir: dirOf(parseFloat(chg)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live runner --------------------------------------------------------------
|
||||||
|
|
||||||
|
function cgUrl(coins, vs) {
|
||||||
|
const ids = coins.map(c => c.id).join(',');
|
||||||
|
const q = new URLSearchParams({ ids, vs_currencies: vs, include_24hr_change: 'true' });
|
||||||
|
return `https://api.coingecko.com/api/v3/simple/price?${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayUri(base, items, vs) {
|
||||||
|
const q = new URLSearchParams({ items: encodeItems(items), cur: vs });
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
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 COINS = cfg.coins || [];
|
||||||
|
const VS = (cfg.vs_currency || 'usd').toLowerCase();
|
||||||
|
const POLL_SEC = cfg.poll_interval_sec || 120;
|
||||||
|
const POS = cfg.position || 'bottom-right';
|
||||||
|
const WIDTH = cfg.width || 1100;
|
||||||
|
const HEIGHT = cfg.height || 110;
|
||||||
|
|
||||||
|
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE || COINS.length === 0) {
|
||||||
|
console.error('config must set api_base, api_token, overlay_base_url, device_id, and at least one coin.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pipId = null;
|
||||||
|
|
||||||
|
async function show(items) {
|
||||||
|
const body = {
|
||||||
|
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, items, VS),
|
||||||
|
position: POS, width: WIDTH, height: HEIGHT, duration: 0,
|
||||||
|
opacity: cfg.opacity != null ? cfg.opacity : 1,
|
||||||
|
border_radius: cfg.border_radius != null ? cfg.border_radius : 14,
|
||||||
|
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'}`);
|
||||||
|
pipId = json.pip_id;
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
if (!pipId) return;
|
||||||
|
try {
|
||||||
|
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 { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
const res = await fetch(cgUrl(COINS, VS), { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`);
|
||||||
|
raw = await res.json();
|
||||||
|
} catch (e) { console.error(`[${new Date().toISOString()}] fetch error: ${e.message}`); return; }
|
||||||
|
|
||||||
|
const items = normalise(raw, { coins: COINS, vs_currency: VS });
|
||||||
|
if (items.length === 0) { console.error(`[${new Date().toISOString()}] no prices for configured coins`); return; }
|
||||||
|
try {
|
||||||
|
await show(items);
|
||||||
|
const line = items.map(i => `${i.symbol} ${CUR_SYMBOL[VS] || ''}${i.priceStr} ${i.changeStr}`).join(' | ');
|
||||||
|
console.log(`[${new Date().toISOString()}] SHOW ticker pip=${pipId} :: ${line}`);
|
||||||
|
} catch (e) { console.error(`[${new Date().toISOString()}] show error: ${e.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Crypto ticker starting — ${COINS.map(c => c.symbol).join(', ')} in ${VS.toUpperCase()}, poll every ${POLL_SEC}s`);
|
||||||
|
await tick();
|
||||||
|
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
clearInterval(timer);
|
||||||
|
console.log('\nclearing ticker overlay before exit...');
|
||||||
|
await clear();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
priceDecimals, addThousands, priceRaw, formatPrice,
|
||||||
|
signedChange, formatChange, dirOf, normalise, encodeItems, decodeItems,
|
||||||
|
cgUrl, overlayUri,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
3
Examples/PIP-Event-Countdown/.gitignore
vendored
Normal file
3
Examples/PIP-Event-Countdown/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
103
Examples/PIP-Event-Countdown/README.md
Normal file
103
Examples/PIP-Event-Countdown/README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
# PiP Event Countdown
|
||||||
|
|
||||||
|
Push a **live, self-clearing countdown overlay** to a ScreenTinker screen (or group) with
|
||||||
|
the PiP API. The overlay ticks down `DD : HH : MM : SS` in real time and — the fun part —
|
||||||
|
**removes itself the instant the target time arrives**. There is no clearing poll: the
|
||||||
|
script sets the PiP `duration` to "seconds until the target", so the player drops the
|
||||||
|
overlay at exactly zero and shows a quick 🎉 first.
|
||||||
|
|
||||||
|
Great for: New Year's Eve, product launches, store opening / closing, shift changes,
|
||||||
|
webinar "starts in…", conference session timers, "back in 15 minutes".
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
countdown.js --(POST /api/pip, type:web, duration = seconds-to-target)--> player
|
||||||
|
|
|
||||||
|
overlay_base_url/countdown-overlay.html?target=<ms>&title=<text> |
|
||||||
|
v
|
||||||
|
countdown-overlay.js ticks the clock every second; at zero shows 🎉 <title>
|
||||||
|
...and the player auto-removes the PiP at the same moment (duration elapsed)
|
||||||
|
```
|
||||||
|
|
||||||
|
`countdown.js` is a **one-shot** push — it doesn't stay running. Re-run it to change the
|
||||||
|
target or title; the player keeps last-show-wins, so the new overlay replaces the old.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `countdown.js` | Computes seconds-to-target and pushes one PiP. `--clear` removes it early. |
|
||||||
|
| `countdown-overlay.html` / `countdown-overlay.js` | The overlay page the player loads in an iframe. Must be served by your ScreenTinker host (same-origin with the player). |
|
||||||
|
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||||
|
| `test.js` | Offline unit test of the date math (`npm test`). |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Mint a token.** In the dashboard create an API token with the **`full`** scope
|
||||||
|
(PiP is fleet-affecting and can render arbitrary web content, so it requires `full`).
|
||||||
|
|
||||||
|
2. **Serve the overlay assets.** Copy `countdown-overlay.html` and `countdown-overlay.js`
|
||||||
|
into the directory your ScreenTinker server serves at the web root (the same place
|
||||||
|
`index.html` is served from — the `frontend/` dir in this repo). They must be reachable
|
||||||
|
at `overlay_base_url`, and **same-origin** with the player so the server's CSP
|
||||||
|
(`script-src 'self'`) allows `countdown-overlay.js`. (Inline scripts are blocked by the
|
||||||
|
CSP — that's why the JS is a separate file.)
|
||||||
|
|
||||||
|
3. **Configure.**
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json: api_base, api_token, overlay_base_url, device_id, target, title
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Run.**
|
||||||
|
```bash
|
||||||
|
node countdown.js
|
||||||
|
# or override target/title on the CLI:
|
||||||
|
node countdown.js "2026-07-04T21:00:00-05:00" "Fireworks!"
|
||||||
|
# clear it early:
|
||||||
|
node countdown.js --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## config.json
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
|-----|---------|
|
||||||
|
| `api_base` | Base URL of your ScreenTinker server, e.g. `https://signage.example.com`. |
|
||||||
|
| `api_token` | A `full`-scope `st_…` token. |
|
||||||
|
| `overlay_base_url` | Public URL of `countdown-overlay.html` (served by your host). |
|
||||||
|
| `device_id` | A device **or** group id to show on. |
|
||||||
|
| `target` | Target datetime, any `Date.parse`-able string (ISO 8601 recommended, include a TZ offset). |
|
||||||
|
| `title` | Heading shown above the clock, and the 🎉 message at zero. |
|
||||||
|
| `position` | `center` (default), `top-right`, `top-left`, `bottom-right`, `bottom-left`. |
|
||||||
|
|
||||||
|
## Local quick-start (this repo's dev instance)
|
||||||
|
|
||||||
|
The dev server runs at `https://localhost:3443/` with a self-signed cert, so disable TLS
|
||||||
|
verification for the run. Copy the overlay assets into the served `frontend/` dir first so
|
||||||
|
`https://localhost:3443/countdown-overlay.html` resolves.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
# config.json:
|
||||||
|
# "api_base": "https://localhost:3443/"
|
||||||
|
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
|
||||||
|
# "overlay_base_url": "https://localhost:3443/countdown-overlay.html"
|
||||||
|
# "device_id": "DEVICE_OR_GROUP_ID"
|
||||||
|
# "target": a time ~2 minutes out, e.g. "2026-06-18T19:42:00-05:00"
|
||||||
|
# "title": "Demo"
|
||||||
|
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node countdown.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Watch the screen count down and disappear on its own at zero. (`config.json` is
|
||||||
|
git-ignored so your token never gets committed.)
|
||||||
|
|
||||||
|
## Notes & limits
|
||||||
|
|
||||||
|
- The PiP `duration` caps at **24h (86400s)**. For a target more than a day out the
|
||||||
|
overlay still shows, but it can't auto-clear at zero — re-run within 24h of the target
|
||||||
|
for the self-clear effect. The script warns you when the target is beyond the cap.
|
||||||
|
- PiP is **ephemeral**: it isn't part of the device's saved layout, so a player reboot
|
||||||
|
clears it. Re-run `countdown.js` after a reboot if needed.
|
||||||
|
- Offline devices are reported, not queued — show it while the screen is online.
|
||||||
11
Examples/PIP-Event-Countdown/config.example.json
Normal file
11
Examples/PIP-Event-Countdown/config.example.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/countdown-overlay.html",
|
||||||
|
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"target": "2026-12-31T23:59:59-06:00",
|
||||||
|
"title": "Happy New Year",
|
||||||
|
"position": "center"
|
||||||
|
}
|
||||||
41
Examples/PIP-Event-Countdown/countdown-overlay.html
Normal file
41
Examples/PIP-Event-Countdown/countdown-overlay.html
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Countdown</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 14px; background: #14161c; color: #fff; border-radius: 16px; overflow: hidden;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 20px 24px; box-sizing: border-box; }
|
||||||
|
.title { font-size: clamp(20px, 5vw, 40px); font-weight: 800; letter-spacing: .02em; text-align: center;
|
||||||
|
line-height: 1.1; }
|
||||||
|
.clock { display: flex; gap: clamp(10px, 3vw, 28px); align-items: flex-start; }
|
||||||
|
.unit { display: flex; flex-direction: column; align-items: center; min-width: clamp(48px, 12vw, 96px); }
|
||||||
|
.num { font-variant-numeric: tabular-nums; font-weight: 800; font-size: clamp(34px, 11vw, 88px);
|
||||||
|
line-height: 1; color: #fff; }
|
||||||
|
.lbl { margin-top: 8px; font-size: clamp(11px, 2.4vw, 16px); text-transform: uppercase; letter-spacing: .12em;
|
||||||
|
color: #9aa0aa; }
|
||||||
|
.sep { font-weight: 800; font-size: clamp(28px, 9vw, 70px); line-height: 1; color: #4b5160; padding-top: 2px; }
|
||||||
|
.done .num, .done .sep { color: #58d68d; }
|
||||||
|
.celebrate { font-size: clamp(30px, 8vw, 68px); font-weight: 800; text-align: center; color: #fff; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card" id="card">
|
||||||
|
<div class="title" id="title">Countdown</div>
|
||||||
|
<div class="clock" id="clock">
|
||||||
|
<div class="unit"><span class="num" id="d">00</span><span class="lbl">Days</span></div>
|
||||||
|
<span class="sep">:</span>
|
||||||
|
<div class="unit"><span class="num" id="h">00</span><span class="lbl">Hours</span></div>
|
||||||
|
<span class="sep">:</span>
|
||||||
|
<div class="unit"><span class="num" id="m">00</span><span class="lbl">Min</span></div>
|
||||||
|
<span class="sep">:</span>
|
||||||
|
<div class="unit"><span class="num" id="s">00</span><span class="lbl">Sec</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="countdown-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
53
Examples/PIP-Event-Countdown/countdown-overlay.js
Normal file
53
Examples/PIP-Event-Countdown/countdown-overlay.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads ?target (epoch ms) and ?title from the URL and ticks a live DD:HH:MM:SS clock.
|
||||||
|
// When the target arrives it switches to a celebratory state. The PiP itself is removed
|
||||||
|
// by the player at the same moment (duration = seconds-to-target), so this is the visual
|
||||||
|
// that the viewer sees right before it vanishes.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var target = parseInt(q.get('target'), 10);
|
||||||
|
var title = (q.get('title') || 'Countdown').trim();
|
||||||
|
|
||||||
|
document.getElementById('title').textContent = title;
|
||||||
|
|
||||||
|
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||||
|
var elD = document.getElementById('d');
|
||||||
|
var elH = document.getElementById('h');
|
||||||
|
var elM = document.getElementById('m');
|
||||||
|
var elS = document.getElementById('s');
|
||||||
|
var clock = document.getElementById('clock');
|
||||||
|
var card = document.getElementById('card');
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
var secs = Math.ceil((target - Date.now()) / 1000);
|
||||||
|
if (!isFinite(target)) { return; }
|
||||||
|
if (secs <= 0) {
|
||||||
|
celebrate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var s = secs;
|
||||||
|
var days = Math.floor(s / 86400); s -= days * 86400;
|
||||||
|
var hours = Math.floor(s / 3600); s -= hours * 3600;
|
||||||
|
var mins = Math.floor(s / 60); s -= mins * 60;
|
||||||
|
elD.textContent = pad(days);
|
||||||
|
elH.textContent = pad(hours);
|
||||||
|
elM.textContent = pad(mins);
|
||||||
|
elS.textContent = pad(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
var celebrated = false;
|
||||||
|
function celebrate() {
|
||||||
|
if (celebrated) { return; }
|
||||||
|
celebrated = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
clock.classList.add('done');
|
||||||
|
elD.textContent = '00'; elH.textContent = '00'; elM.textContent = '00'; elS.textContent = '00';
|
||||||
|
var c = document.createElement('div');
|
||||||
|
c.className = 'celebrate';
|
||||||
|
c.textContent = '🎉 ' + title;
|
||||||
|
card.appendChild(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
var timer = setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
156
Examples/PIP-Event-Countdown/countdown.js
Normal file
156
Examples/PIP-Event-Countdown/countdown.js
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Countdown -> ScreenTinker PiP. Pushes ONE live countdown overlay to a device or
|
||||||
|
// group and lets the player auto-clear it the instant the target time arrives, using
|
||||||
|
// the PiP `duration` field (duration = seconds-to-target, so no clear poll is needed).
|
||||||
|
//
|
||||||
|
// node countdown.js [path/to/config.json]
|
||||||
|
// node countdown.js "2026-12-31T23:59:59-06:00" "Happy New Year" # CLI override
|
||||||
|
// node countdown.js [config] --clear # clear it early
|
||||||
|
//
|
||||||
|
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PIP_DUR_MAX = 86400; // PiP API duration cap (seconds)
|
||||||
|
|
||||||
|
// --- pure, testable helpers (no I/O, explicit `now` so tests are deterministic) ---
|
||||||
|
|
||||||
|
// Whole seconds from `now` until `target` (both epoch ms), rounded UP so the last
|
||||||
|
// partial second still counts. <= 0 means the moment has already passed.
|
||||||
|
function secondsToTarget(target, now) {
|
||||||
|
return Math.ceil((target - now) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a non-negative second count into d/h/m/s. Negative clamps to zero.
|
||||||
|
function breakdown(seconds) {
|
||||||
|
let s = Math.max(0, Math.floor(seconds));
|
||||||
|
const days = Math.floor(s / 86400); s -= days * 86400;
|
||||||
|
const hours = Math.floor(s / 3600); s -= hours * 3600;
|
||||||
|
const minutes = Math.floor(s / 60); s -= minutes * 60;
|
||||||
|
return { days, hours, minutes, seconds: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PiP duration to request: seconds-to-target, but never above the API cap. For targets
|
||||||
|
// more than 24h out the overlay won't auto-clear at zero (it'd hit the cap first); the
|
||||||
|
// CLI warns in that case. 0 would mean "until cleared", which we never want here.
|
||||||
|
function durationForTarget(seconds) {
|
||||||
|
return Math.max(1, Math.min(seconds, PIP_DUR_MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX };
|
||||||
|
|
||||||
|
// --- CLI ---
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const clear = args.includes('--clear');
|
||||||
|
const positional = args.filter(a => !a.startsWith('--'));
|
||||||
|
|
||||||
|
// First positional that isn't an ISO date is treated as the config path.
|
||||||
|
let cfgPath = path.join(__dirname, 'config.json');
|
||||||
|
let cliTarget = null, cliTitle = null;
|
||||||
|
if (positional.length && Number.isFinite(Date.parse(positional[0]))) {
|
||||||
|
cliTarget = positional[0];
|
||||||
|
cliTitle = positional[1] || null;
|
||||||
|
} else if (positional.length) {
|
||||||
|
cfgPath = positional[0];
|
||||||
|
if (positional[1] && Number.isFinite(Date.parse(positional[1]))) {
|
||||||
|
cliTarget = positional[1];
|
||||||
|
cliTitle = positional[2] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = {};
|
||||||
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
||||||
|
catch (e) {
|
||||||
|
if (!cliTarget) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
|
||||||
|
const apiToken = cfg.api_token;
|
||||||
|
const overlayBase = cfg.overlay_base_url;
|
||||||
|
const deviceId = cfg.device_id;
|
||||||
|
const targetIso = cliTarget || cfg.target;
|
||||||
|
const title = cliTitle || cfg.title || 'Countdown';
|
||||||
|
const position = cfg.position || 'center';
|
||||||
|
|
||||||
|
if (!apiBase || !apiToken || !deviceId) {
|
||||||
|
console.error('config must set api_base, api_token, and device_id.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clear) { return doClear(apiBase, apiToken, deviceId); }
|
||||||
|
|
||||||
|
if (!overlayBase) { console.error('config must set overlay_base_url for a countdown overlay.'); process.exit(1); }
|
||||||
|
const targetMs = Date.parse(targetIso);
|
||||||
|
if (!Number.isFinite(targetMs)) { console.error(`invalid target datetime: ${targetIso}`); process.exit(1); }
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const secs = secondsToTarget(targetMs, now);
|
||||||
|
if (secs <= 0) {
|
||||||
|
console.log(`"${title}" target ${targetIso} has already passed — nothing to show.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (secs > PIP_DUR_MAX) {
|
||||||
|
const b = breakdown(secs);
|
||||||
|
console.warn(`note: target is ${b.days}d ${b.hours}h away (> 24h). The overlay will show but auto-clear caps at 24h; re-run within 24h of the target for the self-clear-at-zero effect.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayUri(overlayBase, targetMs, title) {
|
||||||
|
const q = new URLSearchParams({ target: String(targetMs), title: title || '' });
|
||||||
|
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs }) {
|
||||||
|
const duration = durationForTarget(secs);
|
||||||
|
const body = {
|
||||||
|
device_id: deviceId,
|
||||||
|
type: 'web',
|
||||||
|
uri: overlayUri(overlayBase, targetMs, title),
|
||||||
|
position,
|
||||||
|
width: 820,
|
||||||
|
height: 300,
|
||||||
|
duration,
|
||||||
|
border_radius: 16,
|
||||||
|
close_button: false,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/pip`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||||
|
const b = breakdown(secs);
|
||||||
|
console.log(`SHOW "${title}" pip=${json.pip_id} target=${new Date(targetMs).toISOString()}`);
|
||||||
|
console.log(`auto-clears in ${secs}s (${b.days}d ${b.hours}h ${b.minutes}m ${b.seconds}s) — player drops it at zero, no clear call needed.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`pip show failed: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doClear(apiBase, apiToken, deviceId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${apiBase}/api/pip/clear`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ device_id: deviceId }),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||||
|
console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`pip clear failed: ${e.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Examples/PIP-Event-Countdown/package.json
Normal file
12
Examples/PIP-Event-Countdown/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-event-countdown",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: push a live, self-clearing countdown overlay to ScreenTinker screens via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "countdown.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node countdown.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
44
Examples/PIP-Event-Countdown/test.js
Normal file
44
Examples/PIP-Event-Countdown/test.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline unit test for the pure countdown helpers. No network, no player.
|
||||||
|
const { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX } = require('./countdown');
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
function check(name, cond) {
|
||||||
|
console.log(`${cond ? '•' : '✗'} ${name}`);
|
||||||
|
if (!cond) ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed reference instant so the test is deterministic.
|
||||||
|
const now = Date.parse('2026-06-18T12:00:00-05:00');
|
||||||
|
|
||||||
|
// 1 day, 2 hours, 3 minutes, 4 seconds in the future.
|
||||||
|
const futureSecs = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 93784
|
||||||
|
const future = now + futureSecs * 1000;
|
||||||
|
|
||||||
|
const s1 = secondsToTarget(future, now);
|
||||||
|
check(`secondsToTarget future = ${futureSecs}`, s1 === futureSecs);
|
||||||
|
|
||||||
|
const b1 = breakdown(s1);
|
||||||
|
check('breakdown days/hours/min/sec', b1.days === 1 && b1.hours === 2 && b1.minutes === 3 && b1.seconds === 4);
|
||||||
|
|
||||||
|
// Round UP: 1.4s out still counts as 2 whole seconds remaining.
|
||||||
|
check('secondsToTarget rounds up partial second', secondsToTarget(now + 1400, now) === 2);
|
||||||
|
|
||||||
|
// Past target -> non-positive.
|
||||||
|
check('past target <= 0', secondsToTarget(now - 5000, now) <= 0);
|
||||||
|
|
||||||
|
// Exactly now -> 0.
|
||||||
|
check('exactly now == 0', secondsToTarget(now, now) === 0);
|
||||||
|
|
||||||
|
// breakdown clamps negatives to zero.
|
||||||
|
const bz = breakdown(-50);
|
||||||
|
check('breakdown clamps negative to 0', bz.days === 0 && bz.hours === 0 && bz.minutes === 0 && bz.seconds === 0);
|
||||||
|
|
||||||
|
// duration clamp: under the cap is unchanged, over the cap is clamped, zero floors to 1.
|
||||||
|
check('durationForTarget passes through under cap', durationForTarget(3600) === 3600);
|
||||||
|
check('durationForTarget clamps to 24h cap', durationForTarget(PIP_DUR_MAX + 999) === PIP_DUR_MAX);
|
||||||
|
check('durationForTarget floors to >=1', durationForTarget(0) === 1);
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
3
Examples/PIP-Fundraiser-Thermometer/.gitignore
vendored
Normal file
3
Examples/PIP-Fundraiser-Thermometer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
96
Examples/PIP-Fundraiser-Thermometer/README.md
Normal file
96
Examples/PIP-Fundraiser-Thermometer/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# PiP Fundraiser Thermometer
|
||||||
|
|
||||||
|
Pushes a **goal-progress "thermometer"** overlay to a ScreenTinker screen (or group) via
|
||||||
|
the PiP API. Reads a tiny JSON progress doc, computes the percentage, and shows a filling
|
||||||
|
bar with the amount raised, the goal, and the percent. It re-pushes on every poll so the
|
||||||
|
bar updates in place, and clears the overlay when you stop it.
|
||||||
|
|
||||||
|
```
|
||||||
|
progress.json ──poll──▶ thermo.js ──POST /api/pip──▶ ScreenTinker ──▶ screen
|
||||||
|
{raised,goal} (web overlay, duration 0 = persistent)
|
||||||
|
```
|
||||||
|
|
||||||
|
Great for lobby displays, telethons, membership drives, "miles walked", etc.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
A small JSON document, from a local file **or** a URL:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "campaign": "Community Garden", "raised": 12450, "goal": 20000, "currency": "USD" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `source_file` — a path (relative to this dir or absolute). Update the file and the next
|
||||||
|
poll picks it up.
|
||||||
|
- `source_url` — any endpoint returning that JSON (e.g. a Google Sheet published as JSON,
|
||||||
|
a CRM webhook target, your own little script). If both are set, `source_url` wins.
|
||||||
|
|
||||||
|
Supported currency symbols: USD/CAD/AUD/NZD `$`, EUR `€`, GBP `£`, JPY `¥`, INR `₹`.
|
||||||
|
Anything else renders as `CODE 1,234`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Host the overlay page.** Copy both overlay files into the ScreenTinker server's
|
||||||
|
frontend directory so they're served same-origin (the server's CSP only allows the
|
||||||
|
external `<script src>` when it's same-origin):
|
||||||
|
|
||||||
|
```
|
||||||
|
cp thermo-overlay.html thermo-overlay.js /path/to/screentinker/frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
They'll be served at `https://<your-server>/thermo-overlay.html`.
|
||||||
|
|
||||||
|
2. **Create your config:**
|
||||||
|
|
||||||
|
```
|
||||||
|
cp config.example.json config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `api_base`, `api_token` (an `st_` token with the **`full`** scope), `device_id`
|
||||||
|
(a device **or** group id), `overlay_base_url` (the hosted `thermo-overlay.html`), and
|
||||||
|
either `source_file` or `source_url`. Optional: `position` (default `bottom-left`),
|
||||||
|
`width`/`height`, `poll_interval_sec` (default 60), `currency`.
|
||||||
|
|
||||||
|
3. **Run it:**
|
||||||
|
|
||||||
|
```
|
||||||
|
npm start
|
||||||
|
# or: node thermo.js config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop with Ctrl-C — it clears the overlay on the way out.
|
||||||
|
|
||||||
|
## Local quick-start (this repo's dev server)
|
||||||
|
|
||||||
|
The local ScreenTinker dev instance serves on `https://localhost:3443` with a self-signed
|
||||||
|
cert, so prefix commands with `NODE_TLS_REJECT_UNAUTHORIZED=0`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp thermo-overlay.html thermo-overlay.js ../../frontend/ # serve same-origin
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json:
|
||||||
|
# "api_base": "https://localhost:3443/",
|
||||||
|
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
# "overlay_base_url": "https://localhost:3443/thermo-overlay.html",
|
||||||
|
# "device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
# "source_file": "progress.example.json"
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node thermo.js config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `progress.example.json` (bump `raised`) and watch the bar climb on the next poll.
|
||||||
|
When `raised >= goal` the overlay shows **Goal reached! 🎉**.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Offline unit tests for the money formatter and the progress math
|
||||||
|
(`62.25%` → label `62%`, clamps over 100%, divide-by-zero-safe goal). Prints `RESULT: PASS`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- PiP overlays are **ephemeral** — a player reboot drops them; the next poll re-pushes.
|
||||||
|
- `device_id` may be a group id to fan out to every screen in the group.
|
||||||
|
- Cents are dropped on purpose (whole units read better on a wall display).
|
||||||
16
Examples/PIP-Fundraiser-Thermometer/config.example.json
Normal file
16
Examples/PIP-Fundraiser-Thermometer/config.example.json
Normal file
|
|
@ -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/thermo-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"source_file": "progress.example.json",
|
||||||
|
"_source_url_alt": "https://example.com/fundraiser.json",
|
||||||
|
|
||||||
|
"currency": "USD",
|
||||||
|
"poll_interval_sec": 60,
|
||||||
|
|
||||||
|
"position": "bottom-left",
|
||||||
|
"width": 460,
|
||||||
|
"height": 360
|
||||||
|
}
|
||||||
12
Examples/PIP-Fundraiser-Thermometer/package.json
Normal file
12
Examples/PIP-Fundraiser-Thermometer/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-fundraiser-thermometer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: push a fundraiser goal-progress thermometer overlay to ScreenTinker screens via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "thermo.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node thermo.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"campaign": "Community Garden",
|
||||||
|
"raised": 12450,
|
||||||
|
"goal": 20000,
|
||||||
|
"currency": "USD"
|
||||||
|
}
|
||||||
54
Examples/PIP-Fundraiser-Thermometer/test.js
Normal file
54
Examples/PIP-Fundraiser-Thermometer/test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const t = require('./thermo');
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
const eq = (name, got, want) => checks.push({ name, ok: got === want, got, want });
|
||||||
|
|
||||||
|
// money formatting
|
||||||
|
eq('formatMoney USD', t.formatMoney(12450, 'USD'), '$12,450');
|
||||||
|
eq('formatMoney EUR', t.formatMoney(12450, 'EUR'), '€12,450');
|
||||||
|
eq('formatMoney GBP', t.formatMoney(1234567, 'GBP'), '£1,234,567');
|
||||||
|
eq('formatMoney unknown code', t.formatMoney(2500, 'BTC'), 'BTC 2,500');
|
||||||
|
eq('formatMoney small', t.formatMoney(999, 'USD'), '$999');
|
||||||
|
eq('formatMoney rounds', t.formatMoney(12450.7, 'USD'), '$12,451');
|
||||||
|
eq('groupThousands', t.groupThousands(1000000), '1,000,000');
|
||||||
|
|
||||||
|
// progress
|
||||||
|
const p1 = t.computeProgress({ raised: 12450, goal: 20000 });
|
||||||
|
eq('pct 12450/20000', p1.pct, 62.25);
|
||||||
|
eq('pctLabel 12450/20000', p1.pctLabel, '62%');
|
||||||
|
|
||||||
|
const p2 = t.computeProgress({ raised: 25000, goal: 20000 });
|
||||||
|
eq('clamp over 100 pct', p2.pct, 100);
|
||||||
|
eq('clamp over 100 label', p2.pctLabel, '100%');
|
||||||
|
|
||||||
|
const p3 = t.computeProgress({ raised: 500, goal: 0 });
|
||||||
|
eq('goal 0 -> 0 pct', p3.pct, 0);
|
||||||
|
eq('goal 0 -> 0 label', p3.pctLabel, '0%');
|
||||||
|
|
||||||
|
const p4 = t.computeProgress({ raised: 0, goal: 20000 });
|
||||||
|
eq('zero raised', p4.pct, 0);
|
||||||
|
|
||||||
|
// normalise + uri
|
||||||
|
const v = t.normalise({ campaign: 'Community Garden', raised: 12450, goal: 20000, currency: 'USD' });
|
||||||
|
eq('normalise campaign', v.campaign, 'Community Garden');
|
||||||
|
eq('normalise raisedLabel', v.raisedLabel, '$12,450');
|
||||||
|
eq('normalise goalLabel', v.goalLabel, '$20,000');
|
||||||
|
eq('normalise pctLabel', v.pctLabel, '62%');
|
||||||
|
|
||||||
|
const uri = t.overlayUri('https://s/thermo-overlay.html', v);
|
||||||
|
const parsed = new URL(uri);
|
||||||
|
eq('uri campaign round-trips', parsed.searchParams.get('campaign'), 'Community Garden');
|
||||||
|
eq('uri raised round-trips', parsed.searchParams.get('raised'), '$12,450');
|
||||||
|
eq('uri pct round-trips', parsed.searchParams.get('pct'), '62.25');
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
for (const c of checks) {
|
||||||
|
console.log(`${c.ok ? '✓' : '✗'} ${c.name}` + (c.ok ? '' : ` got=${JSON.stringify(c.got)} want=${JSON.stringify(c.want)}`));
|
||||||
|
if (c.ok) pass++;
|
||||||
|
}
|
||||||
|
const ok = pass === checks.length;
|
||||||
|
console.log(`\n${pass}/${checks.length} checks`);
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
46
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.html
Normal file
46
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.html
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Fundraiser Thermometer</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45);
|
||||||
|
padding: 18px 22px; box-sizing: border-box; }
|
||||||
|
.campaign { font-size: clamp(16px, 4vw, 26px); font-weight: 800; letter-spacing: .01em; line-height: 1.15; }
|
||||||
|
.stage { flex: 1; display: flex; align-items: stretch; gap: 18px; margin: 14px 0 10px; }
|
||||||
|
/* vertical thermometer */
|
||||||
|
.thermo { width: 30%; min-width: 64px; display: flex; align-items: flex-end; }
|
||||||
|
.tube { position: relative; width: 100%; height: 100%; background: #2c2c2c; border-radius: 999px;
|
||||||
|
overflow: hidden; border: 2px solid #3a3a3a; }
|
||||||
|
.fill { position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
||||||
|
background: linear-gradient(0deg, #1f9d55, #36d07f);
|
||||||
|
transition: height 1.1s cubic-bezier(.22,.9,.31,1); }
|
||||||
|
.readout { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 6px; }
|
||||||
|
.pct { font-size: clamp(34px, 12vw, 72px); font-weight: 800; line-height: 1; color: #36d07f; }
|
||||||
|
.pct.done { color: #ffd24a; }
|
||||||
|
.amounts { font-size: clamp(15px, 3.4vw, 22px); }
|
||||||
|
.amounts b { font-weight: 800; }
|
||||||
|
.amounts .of { color: #b9b9b9; font-weight: 500; }
|
||||||
|
.footer { font-size: clamp(12px, 2.4vw, 16px); color: #9a9a9a; }
|
||||||
|
.done-banner { color: #ffd24a; font-weight: 800; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="campaign" id="campaign">Fundraiser</div>
|
||||||
|
<div class="stage">
|
||||||
|
<div class="thermo"><div class="tube"><div class="fill" id="fill"></div></div></div>
|
||||||
|
<div class="readout">
|
||||||
|
<div class="pct" id="pct">0%</div>
|
||||||
|
<div class="amounts"><b id="raised">$0</b> <span class="of">of</span> <b id="goal">$0</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer" id="footer"></div>
|
||||||
|
</div>
|
||||||
|
<script src="thermo-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.js
Normal file
32
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the fundraiser fields from the URL query string and fills the thermometer.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||||
|
|
||||||
|
var pct = Math.max(0, Math.min(100, parseFloat(get('pct')) || 0));
|
||||||
|
var pctLabel = get('pctLabel') || (Math.round(pct) + '%');
|
||||||
|
var done = pct >= 100;
|
||||||
|
|
||||||
|
document.getElementById('campaign').textContent = get('campaign') || 'Fundraiser';
|
||||||
|
document.getElementById('raised').textContent = get('raised') || '0';
|
||||||
|
document.getElementById('goal').textContent = get('goal') || '0';
|
||||||
|
|
||||||
|
var pctEl = document.getElementById('pct');
|
||||||
|
pctEl.textContent = pctLabel;
|
||||||
|
if (done) pctEl.classList.add('done');
|
||||||
|
|
||||||
|
var footer = document.getElementById('footer');
|
||||||
|
if (done) {
|
||||||
|
footer.className = 'footer done-banner';
|
||||||
|
footer.textContent = 'Goal reached! 🎉';
|
||||||
|
} else {
|
||||||
|
footer.textContent = 'Thank you for your support';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate the fill from 0 to pct after first paint.
|
||||||
|
var fill = document.getElementById('fill');
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(function () { fill.style.height = pct + '%'; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
170
Examples/PIP-Fundraiser-Thermometer/thermo.js
Normal file
170
Examples/PIP-Fundraiser-Thermometer/thermo.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Fundraiser "thermometer" -> ScreenTinker PiP overlay.
|
||||||
|
//
|
||||||
|
// Reads a tiny JSON progress doc ({ campaign, raised, goal, currency }) from a local
|
||||||
|
// file or a URL, computes the percentage, and pushes a persistent web overlay showing
|
||||||
|
// a filling thermometer bar. Re-pushes each poll so the bar updates in place (the player
|
||||||
|
// keeps a single overlay slot, last-show-wins). Clears the overlay on exit.
|
||||||
|
//
|
||||||
|
// node thermo.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');
|
||||||
|
|
||||||
|
// Currency symbols we render inline; anything else falls back to "CODE 1,234".
|
||||||
|
const CURRENCY_SYMBOLS = { USD: '$', CAD: '$', AUD: '$', NZD: '$', EUR: '€', GBP: '£', JPY: '¥', INR: '₹' };
|
||||||
|
|
||||||
|
// Group an integer with thousands separators without locale surprises.
|
||||||
|
function groupThousands(n) {
|
||||||
|
const neg = n < 0;
|
||||||
|
const digits = String(Math.abs(Math.round(n)));
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < digits.length; i++) {
|
||||||
|
if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
|
||||||
|
out += digits[i];
|
||||||
|
}
|
||||||
|
return (neg ? '-' : '') + out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "$12,450" / "€12,450" / "BTC 12,450" (whole units; cents are noise on a wall display).
|
||||||
|
function formatMoney(amount, currency) {
|
||||||
|
const code = String(currency || 'USD').toUpperCase();
|
||||||
|
const sym = CURRENCY_SYMBOLS[code];
|
||||||
|
const num = groupThousands(Number(amount) || 0);
|
||||||
|
return sym ? `${sym}${num}` : `${code} ${num}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pct is raised/goal clamped to 0..100; pctLabel is the rounded whole-percent string.
|
||||||
|
// Divide-by-zero-safe: goal <= 0 yields 0%.
|
||||||
|
function computeProgress({ raised, goal }) {
|
||||||
|
const r = Number(raised) || 0;
|
||||||
|
const g = Number(goal) || 0;
|
||||||
|
let pct = 0;
|
||||||
|
if (g > 0) pct = (r / g) * 100;
|
||||||
|
pct = Math.max(0, Math.min(100, pct));
|
||||||
|
pct = Math.round(pct * 100) / 100; // keep 2dp for a smooth bar fill
|
||||||
|
return { pct, pctLabel: `${Math.round(pct)}%` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw progress doc -> the fields the overlay displays.
|
||||||
|
function normalise(data, fallbackCurrency) {
|
||||||
|
const currency = data.currency || fallbackCurrency || 'USD';
|
||||||
|
const { pct, pctLabel } = computeProgress(data);
|
||||||
|
return {
|
||||||
|
campaign: data.campaign || 'Fundraiser',
|
||||||
|
raisedLabel: formatMoney(data.raised, currency),
|
||||||
|
goalLabel: formatMoney(data.goal, currency),
|
||||||
|
currency,
|
||||||
|
pct,
|
||||||
|
pctLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayUri(base, view) {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
campaign: view.campaign,
|
||||||
|
raised: view.raisedLabel,
|
||||||
|
goal: view.goalLabel,
|
||||||
|
pct: String(view.pct),
|
||||||
|
pctLabel: view.pctLabel,
|
||||||
|
currency: view.currency,
|
||||||
|
});
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { groupThousands, formatMoney, computeProgress, normalise, overlayUri };
|
||||||
|
|
||||||
|
// ---- runtime (skipped when imported 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 || 60;
|
||||||
|
const POSITION = cfg.position || 'bottom-left';
|
||||||
|
const WIDTH = cfg.width || 460;
|
||||||
|
const HEIGHT = cfg.height || 360;
|
||||||
|
|
||||||
|
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
|
||||||
|
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!cfg.source_file && !cfg.source_url) {
|
||||||
|
console.error('config must set source_file or source_url.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePip = null;
|
||||||
|
|
||||||
|
async function readProgress() {
|
||||||
|
if (cfg.source_url) {
|
||||||
|
const res = await fetch(cfg.source_url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`source HTTP ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
const p = path.isAbsolute(cfg.source_file) ? cfg.source_file : path.join(__dirname, cfg.source_file);
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipShow(view) {
|
||||||
|
const body = {
|
||||||
|
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, view),
|
||||||
|
position: POSITION, width: WIDTH, height: HEIGHT,
|
||||||
|
duration: 0, border_radius: 16, close_button: false,
|
||||||
|
title: view.campaign,
|
||||||
|
};
|
||||||
|
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 (!activePip) return;
|
||||||
|
try {
|
||||||
|
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: activePip }),
|
||||||
|
});
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
activePip = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
const view = normalise(await readProgress(), cfg.currency);
|
||||||
|
activePip = await pipShow(view);
|
||||||
|
console.log(`[${new Date().toISOString()}] SHOW "${view.campaign}" ${view.raisedLabel} of ${view.goalLabel} (${view.pctLabel}) pip=${activePip}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Fundraiser thermometer starting — poll every ${POLL_SEC}s, source=${cfg.source_url || cfg.source_file}`);
|
||||||
|
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();
|
||||||
|
}
|
||||||
3
Examples/PIP-Incident-Webhook/.gitignore
vendored
Normal file
3
Examples/PIP-Incident-Webhook/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
108
Examples/PIP-Incident-Webhook/README.md
Normal file
108
Examples/PIP-Incident-Webhook/README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# PiP Incident Webhook
|
||||||
|
|
||||||
|
An **event-driven** PiP example: a tiny webhook receiver that turns your monitoring
|
||||||
|
stack's alerts into a floating ScreenTinker overlay — perfect for an engineering wall
|
||||||
|
TV or NOC screen.
|
||||||
|
|
||||||
|
- alert **firing** → red overlay appears (kept until cleared)
|
||||||
|
- alert **resolved** → overlay disappears
|
||||||
|
|
||||||
|
Unlike the CAP / NOAA examples (which *poll* a feed), nothing happens here until your
|
||||||
|
alerting system **pushes** to `POST /webhook`. Zero runtime dependencies — just Node 18+
|
||||||
|
(`http` + global `fetch`).
|
||||||
|
|
||||||
|
## Payload shapes
|
||||||
|
|
||||||
|
It accepts either:
|
||||||
|
|
||||||
|
**Generic** (great for `curl`, cron jobs, custom scripts):
|
||||||
|
```json
|
||||||
|
{ "status": "firing", "key": "db-down", "title": "Primary DB unreachable", "detail": "conn refused on 5432", "severity": "critical" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prometheus Alertmanager** (point a `webhook_config` straight at it):
|
||||||
|
```json
|
||||||
|
{ "status": "firing", "alerts": [
|
||||||
|
{ "status": "firing", "fingerprint": "abc123",
|
||||||
|
"labels": { "alertname": "HighCPU", "severity": "warning", "instance": "web-1" },
|
||||||
|
"annotations": { "summary": "CPU > 90%", "description": "web-1 hot for 5m" } }
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`severity` drives the band colour: `critical`→dark red, `warning`→orange, `info`→amber,
|
||||||
|
anything else→red. The `key` (or Alertmanager `fingerprint`) is what matches a later
|
||||||
|
*resolve* back to the overlay it should clear.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. `cp config.example.json config.json` and fill in:
|
||||||
|
- `api_token` — an `st_` API token with the **`full`** scope.
|
||||||
|
- `api_base` / `overlay_base_url` — your signage server.
|
||||||
|
- `device_id` — a device **or** group id.
|
||||||
|
- `shared_secret` *(optional)* — if set, callers must send it as the `X-Webhook-Secret`
|
||||||
|
header or `?secret=` query param.
|
||||||
|
2. **Serve the overlay assets.** The overlay is a `web` PiP rendered in an iframe, so the
|
||||||
|
player fetches `overlay_base_url` directly. Copy `incident-overlay.html` and
|
||||||
|
`incident-overlay.js` into the directory your signage server serves at the web root
|
||||||
|
(e.g. the server's `frontend/` dir) so that `https://<server>/incident-overlay.html`
|
||||||
|
resolves. They must be **same-origin** with the player (the server CSP only allows
|
||||||
|
same-origin scripts — that's why the JS is an external `incident-overlay.js`, not inline).
|
||||||
|
3. `node server.js` (or `npm start`).
|
||||||
|
|
||||||
|
## Local quick-start (this repo's dev server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json:
|
||||||
|
# "api_base": "https://localhost:3443/"
|
||||||
|
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
|
||||||
|
# "overlay_base_url": "https://localhost:3443/incident-overlay.html"
|
||||||
|
# "device_id": "DEVICE_OR_GROUP_ID"
|
||||||
|
|
||||||
|
# copy the overlay assets into the server's web root (served same-origin as the player):
|
||||||
|
cp incident-overlay.html incident-overlay.js ../../frontend/
|
||||||
|
|
||||||
|
# self-signed cert on localhost -> let Node accept it:
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Then drive it with `curl`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# fire a critical incident -> red overlay appears on the player
|
||||||
|
curl -s localhost:8088/webhook -H 'Content-Type: application/json' -d \
|
||||||
|
'{"status":"firing","key":"db-down","title":"Primary DB unreachable","detail":"conn refused on 5432","severity":"critical"}'
|
||||||
|
|
||||||
|
# ...later, resolve it -> overlay clears
|
||||||
|
curl -s localhost:8088/webhook -H 'Content-Type: application/json' -d \
|
||||||
|
'{"status":"resolved","key":"db-down"}'
|
||||||
|
|
||||||
|
# health
|
||||||
|
curl -s localhost:8088/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
`Ctrl-C` clears any still-showing overlays before exiting.
|
||||||
|
|
||||||
|
> Heads-up: this dev box has a shared player. If someone else is demoing on
|
||||||
|
> `d7c88aa0-…`, point `device_id` at your own device/group instead.
|
||||||
|
|
||||||
|
## Wire up Alertmanager
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# alertmanager.yml
|
||||||
|
route:
|
||||||
|
receiver: signage
|
||||||
|
receivers:
|
||||||
|
- name: signage
|
||||||
|
webhook_configs:
|
||||||
|
- url: http://YOUR_HOST:8088/webhook
|
||||||
|
send_resolved: true # so "resolved" clears the overlay
|
||||||
|
```
|
||||||
|
|
||||||
|
If you set a `shared_secret`, append it to the URL: `...:8088/webhook?secret=YOUR_SECRET`.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # offline; exercises both payload shapes + the colour map
|
||||||
|
```
|
||||||
15
Examples/PIP-Incident-Webhook/config.example.json
Normal file
15
Examples/PIP-Incident-Webhook/config.example.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"listen_port": 8088,
|
||||||
|
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/incident-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"position": "top-right",
|
||||||
|
"source_label": "Monitoring",
|
||||||
|
|
||||||
|
"shared_secret": null,
|
||||||
|
|
||||||
|
"overlay": { "width": 760, "height": 280, "border_radius": 16, "opacity": 1 }
|
||||||
|
}
|
||||||
40
Examples/PIP-Incident-Webhook/incident-overlay.html
Normal file
40
Examples/PIP-Incident-Webhook/incident-overlay.html
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Incident</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||||
|
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
|
||||||
|
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(16px, 3.4vw, 26px); }
|
||||||
|
.band .pulse { width: 15px; height: 15px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||||
|
animation: pulse 1.1s ease-in-out infinite; flex: none; }
|
||||||
|
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||||
|
.band .badge { margin-left: auto; font-size: clamp(11px, 2vw, 14px); font-weight: 700;
|
||||||
|
letter-spacing: .08em; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.28); }
|
||||||
|
.body { padding: 16px 24px 18px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||||
|
.title { font-size: clamp(19px, 4.4vw, 34px); font-weight: 700; line-height: 1.15; }
|
||||||
|
.detail { font-size: clamp(14px, 2.8vw, 20px); color: #d6d6d6; line-height: 1.3;
|
||||||
|
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; }
|
||||||
|
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="band" id="band">
|
||||||
|
<span class="pulse"></span><span id="level">INCIDENT</span>
|
||||||
|
<span class="badge" id="badge"></span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="title" id="title"></div>
|
||||||
|
<div class="detail" id="detail"></div>
|
||||||
|
<div class="footer"><span id="source"></span> <span id="updated"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="incident-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
Examples/PIP-Incident-Webhook/incident-overlay.js
Normal file
23
Examples/PIP-Incident-Webhook/incident-overlay.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the incident fields from the URL query string and paints the card.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||||
|
|
||||||
|
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||||
|
document.getElementById('band').style.background = color;
|
||||||
|
|
||||||
|
var sev = (get('severity') || 'alert');
|
||||||
|
document.getElementById('level').textContent = (get('level') || 'INCIDENT').toUpperCase();
|
||||||
|
document.getElementById('badge').textContent = sev.toUpperCase();
|
||||||
|
|
||||||
|
document.getElementById('title').textContent = get('title') || 'Service incident';
|
||||||
|
document.getElementById('detail').textContent = get('detail') || '';
|
||||||
|
document.getElementById('source').textContent = get('source') || '';
|
||||||
|
|
||||||
|
var updated = get('updated');
|
||||||
|
if (updated) {
|
||||||
|
var d = new Date(updated);
|
||||||
|
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· ' + d.toLocaleString());
|
||||||
|
}
|
||||||
|
})();
|
||||||
12
Examples/PIP-Incident-Webhook/package.json
Normal file
12
Examples/PIP-Incident-Webhook/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-incident-webhook",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: an inbound webhook receiver (Alertmanager / generic) that pushes a red ScreenTinker PiP overlay on incident firing and clears it on resolve.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
235
Examples/PIP-Incident-Webhook/server.js
Normal file
235
Examples/PIP-Incident-Webhook/server.js
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Event-driven PiP: an inbound webhook receiver. Instead of polling a feed, it waits for
|
||||||
|
// your monitoring stack to PUSH it incidents, then shows / clears a ScreenTinker PiP overlay
|
||||||
|
// in real time:
|
||||||
|
// - status "firing" -> POST /api/pip (red overlay, kept until cleared)
|
||||||
|
// - status "resolved" -> POST /api/pip/clear
|
||||||
|
//
|
||||||
|
// Accepts two payload shapes on POST /webhook:
|
||||||
|
// (a) generic { status:"firing"|"resolved", key, title, detail, severity }
|
||||||
|
// (b) Alertmanager{ status, alerts:[{ status, labels:{alertname,severity,...},
|
||||||
|
// annotations:{summary,description}, fingerprint }] }
|
||||||
|
//
|
||||||
|
// node server.js [path/to/config.json]
|
||||||
|
//
|
||||||
|
// Node 18+ (built-in http + global fetch). Needs an st_ API token with the 'full' scope.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
// --- pure logic (unit-tested in test.js; no network) -------------------------------------
|
||||||
|
|
||||||
|
// severity -> overlay band colour (#RRGGBB, the PiP colour contract).
|
||||||
|
const SEV_COLORS = { critical: '7B0000', warning: 'E8730C', info: 'F2C200' };
|
||||||
|
const DEFAULT_COLOR = 'CC0000';
|
||||||
|
|
||||||
|
function colorFor(severity) {
|
||||||
|
return SEV_COLORS[String(severity || '').toLowerCase()] || DEFAULT_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map "firing"/"resolved" (and Alertmanager's per-alert status) to our two states.
|
||||||
|
function stateOf(status) {
|
||||||
|
return String(status || '').toLowerCase() === 'resolved' ? 'resolved' : 'firing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise either payload shape into a flat list of incidents:
|
||||||
|
// { key, state:"firing"|"resolved", title, detail, severity }
|
||||||
|
// `key` is the stable identity used to match a later resolve to its overlay.
|
||||||
|
function normalise(payload) {
|
||||||
|
const p = payload || {};
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
if (Array.isArray(p.alerts)) {
|
||||||
|
// Alertmanager group webhook. Each alert may carry its own status; fall back to the
|
||||||
|
// group status. fingerprint is Alertmanager's stable per-alert id.
|
||||||
|
for (const a of p.alerts) {
|
||||||
|
const labels = a.labels || {};
|
||||||
|
const ann = a.annotations || {};
|
||||||
|
const name = labels.alertname || ann.summary || 'alert';
|
||||||
|
out.push({
|
||||||
|
key: a.fingerprint || `${name}:${JSON.stringify(labels.instance || labels.job || '')}`,
|
||||||
|
state: stateOf(a.status || p.status),
|
||||||
|
title: ann.summary || name,
|
||||||
|
detail: ann.description || '',
|
||||||
|
severity: (labels.severity || 'warning').toLowerCase(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic single-incident shape.
|
||||||
|
const name = p.title || p.key || 'incident';
|
||||||
|
out.push({
|
||||||
|
key: p.key || name,
|
||||||
|
state: stateOf(p.status),
|
||||||
|
title: p.title || name,
|
||||||
|
detail: p.detail || '',
|
||||||
|
severity: (p.severity || 'warning').toLowerCase(),
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the overlay iframe URL from an incident.
|
||||||
|
function overlayUri(base, inc, sourceLabel, nowIso) {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
level: 'incident',
|
||||||
|
title: inc.title || '',
|
||||||
|
detail: inc.detail || '',
|
||||||
|
severity: inc.severity || '',
|
||||||
|
color: colorFor(inc.severity),
|
||||||
|
source: sourceLabel || '',
|
||||||
|
updated: nowIso || '',
|
||||||
|
});
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { colorFor, stateOf, normalise, overlayUri, SEV_COLORS, DEFAULT_COLOR };
|
||||||
|
|
||||||
|
// --- server (only when run directly) -----------------------------------------------------
|
||||||
|
|
||||||
|
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 PORT = cfg.listen_port || 8088;
|
||||||
|
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 POSITION = cfg.position || 'top-right';
|
||||||
|
const SOURCE_LABEL = cfg.source_label || 'Monitoring';
|
||||||
|
const SECRET = cfg.shared_secret || null;
|
||||||
|
const OVERLAY = cfg.overlay || {};
|
||||||
|
|
||||||
|
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
|
||||||
|
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// key -> pip_id of the overlay currently showing for that incident.
|
||||||
|
const active = new Map();
|
||||||
|
const nowIso = () => new Date().toISOString();
|
||||||
|
|
||||||
|
async function pipShow(inc) {
|
||||||
|
const body = {
|
||||||
|
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, inc, SOURCE_LABEL, nowIso()),
|
||||||
|
position: POSITION,
|
||||||
|
width: OVERLAY.width || 760, height: OVERLAY.height || 280,
|
||||||
|
duration: 0, // keep until we clear it on resolve
|
||||||
|
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||||
|
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||||
|
close_button: false,
|
||||||
|
title: inc.title,
|
||||||
|
};
|
||||||
|
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, pip_id: pipId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIncidents(incidents) {
|
||||||
|
const summary = { fired: 0, cleared: 0, skipped: 0 };
|
||||||
|
for (const inc of incidents) {
|
||||||
|
if (!inc.key) { summary.skipped++; continue; }
|
||||||
|
try {
|
||||||
|
if (inc.state === 'firing') {
|
||||||
|
if (active.has(inc.key)) { // refresh: clear the old card, show the new
|
||||||
|
try { await pipClear(active.get(inc.key)); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
const pipId = await pipShow(inc);
|
||||||
|
active.set(inc.key, pipId);
|
||||||
|
summary.fired++;
|
||||||
|
console.log(`[${nowIso()}] FIRING "${inc.title}" (${inc.severity}) key=${inc.key} pip=${pipId}`);
|
||||||
|
} else {
|
||||||
|
const pipId = active.get(inc.key);
|
||||||
|
if (pipId) {
|
||||||
|
await pipClear(pipId);
|
||||||
|
active.delete(inc.key);
|
||||||
|
summary.cleared++;
|
||||||
|
console.log(`[${nowIso()}] RESOLVED key=${inc.key} pip=${pipId} (cleared)`);
|
||||||
|
} else {
|
||||||
|
summary.skipped++;
|
||||||
|
console.log(`[${nowIso()}] RESOLVED key=${inc.key} (nothing showing)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
summary.skipped++;
|
||||||
|
console.error(`[${nowIso()}] error for key=${inc.key}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authOk(req, url) {
|
||||||
|
if (!SECRET) return true;
|
||||||
|
const hdr = req.headers['x-webhook-secret'];
|
||||||
|
const qs = url.searchParams.get('secret');
|
||||||
|
return hdr === SECRET || qs === SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req, cap = 1_000_000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let n = 0; const chunks = [];
|
||||||
|
req.on('data', (c) => { n += c.length; if (n > cap) { reject(new Error('body too large')); req.destroy(); } else chunks.push(c); });
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||||
|
const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||||
|
return send(200, { ok: true, active: active.size });
|
||||||
|
}
|
||||||
|
if (req.method !== 'POST' || url.pathname !== '/webhook') {
|
||||||
|
return send(404, { error: 'POST /webhook or GET /healthz' });
|
||||||
|
}
|
||||||
|
if (!authOk(req, url)) return send(401, { error: 'bad or missing shared secret' });
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(await readBody(req) || '{}'); }
|
||||||
|
catch (e) { return send(400, { error: `invalid JSON: ${e.message}` }); }
|
||||||
|
|
||||||
|
const incidents = normalise(payload);
|
||||||
|
const summary = await handleIncidents(incidents);
|
||||||
|
send(200, { ok: true, received: incidents.length, ...summary });
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Incident webhook receiver listening on :${PORT}`);
|
||||||
|
console.log(` POST /webhook (generic or Alertmanager JSON)${SECRET ? ' [shared secret required]' : ''}`);
|
||||||
|
console.log(` GET /healthz`);
|
||||||
|
console.log(` -> device ${DEVICE} @ ${API_BASE}, overlay ${OVERLAY_BASE}, position ${POSITION}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
console.log('\nclearing active overlays before exit...');
|
||||||
|
for (const pipId of active.values()) { try { await pipClear(pipId); } catch { /* best effort */ } }
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 1500).unref();
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
55
Examples/PIP-Incident-Webhook/test.js
Normal file
55
Examples/PIP-Incident-Webhook/test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline unit test for the pure normalise()/colorFor()/overlayUri() logic. No network.
|
||||||
|
const { normalise, colorFor, overlayUri } = require('./server');
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
const check = (cond, msg) => { console.log(`${cond ? '✓' : '✗'} ${msg}`); if (!cond) ok = false; };
|
||||||
|
|
||||||
|
// --- generic shape: firing -----------------------------------------------------------------
|
||||||
|
const gFire = normalise({ status: 'firing', key: 'db-down', title: 'Primary DB unreachable', detail: 'conn refused on 5432', severity: 'critical' });
|
||||||
|
check(gFire.length === 1, 'generic firing -> 1 incident');
|
||||||
|
check(gFire[0].key === 'db-down', 'generic key preserved');
|
||||||
|
check(gFire[0].state === 'firing', 'generic state=firing');
|
||||||
|
check(gFire[0].title === 'Primary DB unreachable', 'generic title');
|
||||||
|
check(gFire[0].severity === 'critical', 'generic severity');
|
||||||
|
|
||||||
|
// --- generic shape: resolved ---------------------------------------------------------------
|
||||||
|
const gRes = normalise({ status: 'RESOLVED', key: 'db-down' });
|
||||||
|
check(gRes[0].state === 'resolved', 'generic resolved (case-insensitive) -> state=resolved');
|
||||||
|
check(gRes[0].key === 'db-down', 'generic resolved key matches the firing key');
|
||||||
|
|
||||||
|
// --- Alertmanager shape: mixed firing + resolved -------------------------------------------
|
||||||
|
const am = normalise({
|
||||||
|
status: 'firing',
|
||||||
|
alerts: [
|
||||||
|
{ status: 'firing', fingerprint: 'abc123',
|
||||||
|
labels: { alertname: 'HighCPU', severity: 'warning', instance: 'web-1' },
|
||||||
|
annotations: { summary: 'CPU > 90%', description: 'web-1 hot for 5m' } },
|
||||||
|
{ status: 'resolved', fingerprint: 'def456',
|
||||||
|
labels: { alertname: 'DiskFull', severity: 'critical' },
|
||||||
|
annotations: { summary: 'Disk 99%', description: '/var almost full' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
check(am.length === 2, 'alertmanager -> 2 incidents');
|
||||||
|
check(am[0].key === 'abc123' && am[0].state === 'firing', 'AM[0] fingerprint key + firing');
|
||||||
|
check(am[0].title === 'CPU > 90%' && am[0].detail === 'web-1 hot for 5m', 'AM[0] summary/description mapped');
|
||||||
|
check(am[0].severity === 'warning', 'AM[0] severity from labels');
|
||||||
|
check(am[1].key === 'def456' && am[1].state === 'resolved', 'AM[1] resolved per-alert status overrides group');
|
||||||
|
check(am[1].severity === 'critical', 'AM[1] severity critical');
|
||||||
|
|
||||||
|
// --- severity -> colour --------------------------------------------------------------------
|
||||||
|
check(colorFor('critical') === '7B0000', 'colour critical');
|
||||||
|
check(colorFor('warning') === 'E8730C', 'colour warning');
|
||||||
|
check(colorFor('info') === 'F2C200', 'colour info');
|
||||||
|
check(colorFor('weird') === 'CC0000', 'colour default fallback');
|
||||||
|
check(colorFor() === 'CC0000', 'colour missing -> default');
|
||||||
|
|
||||||
|
// --- overlay uri ---------------------------------------------------------------------------
|
||||||
|
const uri = overlayUri('https://x/incident-overlay.html', am[0], 'Alertmanager', '2026-06-18T10:00:00Z');
|
||||||
|
check(uri.startsWith('https://x/incident-overlay.html?'), 'uri keeps base + adds query');
|
||||||
|
check(/color=E8730C/.test(uri), 'uri carries severity colour');
|
||||||
|
check(/title=CPU/.test(uri), 'uri carries title');
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
3
Examples/PIP-News-Ticker/.gitignore
vendored
Normal file
3
Examples/PIP-News-Ticker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
96
Examples/PIP-News-Ticker/README.md
Normal file
96
Examples/PIP-News-Ticker/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# PIP News Ticker
|
||||||
|
|
||||||
|
A scrolling RSS/Atom headline ticker pushed to a ScreenTinker screen (or group) via the
|
||||||
|
PiP overlay API. Polls any feed, extracts the latest headlines, and renders a continuous
|
||||||
|
right-to-left strip along the bottom of the screen. Keyless and zero-dependency.
|
||||||
|
|
||||||
|
```
|
||||||
|
RSS/Atom feed ──poll──> news.js ──POST /api/pip (type:web)──> player
|
||||||
|
│ │
|
||||||
|
parse headlines iframe loads news-overlay.html
|
||||||
|
join with separator scrolls the strip seamlessly
|
||||||
|
```
|
||||||
|
|
||||||
|
The overlay is **persistent** (`duration: 0`) and refreshed on every poll (the player keeps a
|
||||||
|
single overlay slot, last-show-wins), so headlines update in place. The ticker is cleared when
|
||||||
|
you stop the script (Ctrl-C).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `news.js` | Poller + PiP pusher. Hand-rolled RSS/Atom parser (`parseHeadlines`, `feedLabel`). |
|
||||||
|
| `news-overlay.html` / `news-overlay.js` | The strip overlay. Served same-origin; reads `?text`/`?label`/`?sep`; external JS (no inline) so the server CSP allows it. |
|
||||||
|
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||||
|
| `fixture-feed.xml`, `test.js` | Offline test (no network). |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Host the overlay.** Copy both overlay files into the signage server's web root so they're
|
||||||
|
served from the same origin as the player (the server applies `Content-Security-Policy:
|
||||||
|
script-src 'self'`, which is why the JS is external rather than inline):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp news-overlay.html news-overlay.js /path/to/screentinker/frontend/
|
||||||
|
```
|
||||||
|
|
||||||
|
They'll be reachable at `https://<your-server>/news-overlay.html`.
|
||||||
|
|
||||||
|
2. **Create an API token** with the `full` scope (PiP is a fleet-affecting, full-trust action).
|
||||||
|
|
||||||
|
3. **Configure.** Copy `config.example.json` to `config.json` and set `api_base`, `api_token`,
|
||||||
|
`overlay_base_url`, `device_id` (a device **or** group id), and your `feed_url`. Optional:
|
||||||
|
`label` (left chip text; defaults to the feed's channel title), `max_items`, `separator`,
|
||||||
|
`poll_interval_sec`, and overlay geometry (`position`, `width`, `height`).
|
||||||
|
|
||||||
|
4. **Run.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm start # or: node news.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop with Ctrl-C to clear the ticker.
|
||||||
|
|
||||||
|
## Local quick-start (self-signed dev server)
|
||||||
|
|
||||||
|
Against a local ScreenTinker dev instance with a self-signed certificate:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp news-overlay.html news-overlay.js /path/to/screentinker/frontend/
|
||||||
|
|
||||||
|
cat > config.json <<'JSON'
|
||||||
|
{
|
||||||
|
"api_base": "https://localhost:3443/",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://localhost:3443/news-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
"feed_url": "https://feeds.bbci.co.uk/news/rss.xml",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 90,
|
||||||
|
"poll_interval_sec": 300
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node news.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`NODE_TLS_REJECT_UNAUTHORIZED=0` is only for trusting the dev box's self-signed cert — don't
|
||||||
|
use it against production.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs `test.js` against `fixture-feed.xml` (offline): verifies headline extraction order,
|
||||||
|
CDATA/entity decoding, `max_items` capping, channel-title labelling, and overlay-URI round-trip.
|
||||||
|
Prints `RESULT: PASS ✅`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The parser handles RSS (`<item><title>`) and Atom (`<entry><title>`), decodes CDATA and common
|
||||||
|
XML entities, and strips stray markup from titles. It's deliberately tolerant rather than a full
|
||||||
|
XML parser, so it copes with the messy real-world feeds you'll point it at.
|
||||||
|
- Headline text is rendered with `textContent` only — feed content is never injected as HTML.
|
||||||
18
Examples/PIP-News-Ticker/config.example.json
Normal file
18
Examples/PIP-News-Ticker/config.example.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/news-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"feed_url": "https://feeds.bbci.co.uk/news/rss.xml",
|
||||||
|
"label": null,
|
||||||
|
"max_items": 12,
|
||||||
|
"separator": " • ",
|
||||||
|
"poll_interval_sec": 300,
|
||||||
|
|
||||||
|
"position": "bottom-right",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 90,
|
||||||
|
"border_radius": 12,
|
||||||
|
"opacity": 1
|
||||||
|
}
|
||||||
28
Examples/PIP-News-Ticker/fixture-feed.xml
Normal file
28
Examples/PIP-News-Ticker/fixture-feed.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>Demo Newsroom</title>
|
||||||
|
<link>https://example.com/</link>
|
||||||
|
<description>Fixture feed for the news ticker offline test</description>
|
||||||
|
<item>
|
||||||
|
<title>City council approves new transit line</title>
|
||||||
|
<link>https://example.com/1</link>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title><![CDATA[Markets rally as <b>tech</b> shares climb]]></title>
|
||||||
|
<link>https://example.com/2</link>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Storms & flooding expected this weekend</title>
|
||||||
|
<link>https://example.com/3</link>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Local team wins championship 3–2</title>
|
||||||
|
<link>https://example.com/4</link>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Library extends weekend hours</title>
|
||||||
|
<link>https://example.com/5</link>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
31
Examples/PIP-News-Ticker/news-overlay.html
Normal file
31
Examples/PIP-News-Ticker/news-overlay.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>News Ticker</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; overflow: hidden; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.strip { flex: 1; display: flex; align-items: stretch; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.45); }
|
||||||
|
.chip { display: flex; align-items: center; gap: 10px; padding: 0 18px; background: #CC0000;
|
||||||
|
font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
|
||||||
|
font-size: clamp(14px, 2.4vw, 22px); white-space: nowrap; flex: 0 0 auto; }
|
||||||
|
.chip .pulse { width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||||
|
animation: pulse 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||||
|
.viewport { position: relative; flex: 1; overflow: hidden; display: flex; align-items: center; }
|
||||||
|
.track { position: absolute; white-space: nowrap; will-change: transform;
|
||||||
|
font-size: clamp(16px, 2.8vw, 26px); font-weight: 600; line-height: 1; }
|
||||||
|
.track .sep { color: #CC0000; padding: 0 2px; font-weight: 800; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="strip">
|
||||||
|
<div class="chip"><span class="pulse"></span><span id="label">NEWS</span></div>
|
||||||
|
<div class="viewport"><div class="track" id="track"></div></div>
|
||||||
|
</div>
|
||||||
|
<script src="news-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
Examples/PIP-News-Ticker/news-overlay.js
Normal file
60
Examples/PIP-News-Ticker/news-overlay.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the headline string from the query and scrolls it right-to-left, seamlessly.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var text = (q.get('text') || '').trim();
|
||||||
|
var label = (q.get('label') || 'NEWS').trim();
|
||||||
|
var sep = q.get('sep') || ' • ';
|
||||||
|
|
||||||
|
document.getElementById('label').textContent = label;
|
||||||
|
|
||||||
|
var track = document.getElementById('track');
|
||||||
|
var viewport = track.parentNode;
|
||||||
|
|
||||||
|
// Build one "run" of the content (separator-joined headlines). Splitting on the
|
||||||
|
// separator lets us colour the dividers without trusting feed markup (textContent only).
|
||||||
|
function buildRun(container) {
|
||||||
|
var parts = text.length ? text.split(sep) : ['(no headlines)'];
|
||||||
|
parts.forEach(function (p, i) {
|
||||||
|
if (i > 0) {
|
||||||
|
var s = document.createElement('span');
|
||||||
|
s.className = 'sep';
|
||||||
|
s.textContent = sep;
|
||||||
|
container.appendChild(s);
|
||||||
|
}
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.textContent = p;
|
||||||
|
container.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two identical runs back-to-back → when the first scrolls fully off, reset by one
|
||||||
|
// run width for a seamless loop.
|
||||||
|
buildRun(track);
|
||||||
|
var gap = document.createElement('span');
|
||||||
|
gap.textContent = sep;
|
||||||
|
gap.className = 'sep';
|
||||||
|
track.appendChild(gap);
|
||||||
|
var runWidth = 0;
|
||||||
|
|
||||||
|
function measureAndStart() {
|
||||||
|
runWidth = track.scrollWidth; // width of a single run (+ trailing sep)
|
||||||
|
buildRun(track); // append the second copy for the wrap
|
||||||
|
var x = viewport.clientWidth; // start just off the right edge
|
||||||
|
var speed = 90; // px/sec
|
||||||
|
var last = null;
|
||||||
|
function frame(ts) {
|
||||||
|
if (last == null) last = ts;
|
||||||
|
var dt = (ts - last) / 1000; last = ts;
|
||||||
|
x -= speed * dt;
|
||||||
|
if (x <= -runWidth) x += runWidth; // wrap by exactly one run
|
||||||
|
track.style.transform = 'translateX(' + x + 'px)';
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a tick so fonts/layout settle before measuring.
|
||||||
|
if (document.readyState === 'complete') measureAndStart();
|
||||||
|
else window.addEventListener('load', measureAndStart);
|
||||||
|
})();
|
||||||
166
Examples/PIP-News-Ticker/news.js
Normal file
166
Examples/PIP-News-Ticker/news.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// RSS/Atom headline ticker -> ScreenTinker PiP. Polls a feed, extracts headlines,
|
||||||
|
// and pushes a persistent scrolling strip overlay to a device/group. Refreshes the
|
||||||
|
// strip on each poll (player single-slot, last-show-wins) and clears on exit.
|
||||||
|
//
|
||||||
|
// node news.js [path/to/config.json]
|
||||||
|
//
|
||||||
|
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||||
|
// Zero dependencies — the feed parser is hand-rolled and tolerant of RSS and Atom.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ---- pure helpers (exported for the offline test) -------------------------
|
||||||
|
|
||||||
|
// Decode CDATA sections and the handful of XML entities feeds actually use.
|
||||||
|
function decodeText(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
let t = String(s);
|
||||||
|
// pull CDATA payloads out verbatim
|
||||||
|
t = t.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
|
||||||
|
// strip any stray tags (some feeds put markup in titles)
|
||||||
|
t = t.replace(/<[^>]+>/g, '');
|
||||||
|
// named + numeric entities
|
||||||
|
t = t
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||||
|
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||||
|
.replace(/&/g, '&'); // ampersand last, so &lt; -> < not <
|
||||||
|
return t.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grab the first <title>…</title> inside a block (RSS item / Atom entry).
|
||||||
|
function firstTitle(block) {
|
||||||
|
const m = block.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
|
return m ? decodeText(m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tolerant headline extraction. Handles RSS (<item>) and Atom (<entry>); falls back
|
||||||
|
// gracefully if a feed is malformed. Returns up to maxItems non-empty titles in order.
|
||||||
|
function parseHeadlines(xml, maxItems = 12) {
|
||||||
|
const text = String(xml || '');
|
||||||
|
let blocks = text.match(/<item\b[\s\S]*?<\/item>/gi);
|
||||||
|
if (!blocks || blocks.length === 0) blocks = text.match(/<entry\b[\s\S]*?<\/entry>/gi);
|
||||||
|
const out = [];
|
||||||
|
for (const b of blocks || []) {
|
||||||
|
const title = firstTitle(b);
|
||||||
|
if (title) out.push(title);
|
||||||
|
if (out.length >= maxItems) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed channel/source title, used as the left-hand chip label when present.
|
||||||
|
function feedLabel(xml) {
|
||||||
|
const text = String(xml || '');
|
||||||
|
// RSS: channel > title (the first <title> before any <item>)
|
||||||
|
const beforeItem = text.split(/<item\b/i)[0];
|
||||||
|
const ch = beforeItem.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
||||||
|
if (ch) return decodeText(ch[1]);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverlayUri(base, { text, label, sep }) {
|
||||||
|
const q = new URLSearchParams({ text: text || '', label: label || '', sep: sep || ' • ' });
|
||||||
|
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- live runner ----------------------------------------------------------
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
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); }
|
||||||
|
if (!cfg.api_base || !cfg.api_token || !cfg.overlay_base_url || !cfg.device_id || !cfg.feed_url) {
|
||||||
|
console.error('config must set api_base, api_token, overlay_base_url, device_id, and feed_url.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipShow(cfg, uri) {
|
||||||
|
const base = cfg.api_base.replace(/\/$/, '');
|
||||||
|
const body = {
|
||||||
|
device_id: cfg.device_id,
|
||||||
|
type: 'web',
|
||||||
|
uri,
|
||||||
|
position: cfg.position || 'bottom-right',
|
||||||
|
width: cfg.width || 1200,
|
||||||
|
height: cfg.height || 90,
|
||||||
|
duration: 0, // persistent until we clear it
|
||||||
|
border_radius: cfg.border_radius != null ? cfg.border_radius : 12,
|
||||||
|
opacity: cfg.opacity != null ? cfg.opacity : 1,
|
||||||
|
};
|
||||||
|
const res = await fetch(`${base}/api/pip`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.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(cfg, pipId) {
|
||||||
|
const base = cfg.api_base.replace(/\/$/, '');
|
||||||
|
await fetch(`${base}/api/pip/clear`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
|
||||||
|
body: JSON.stringify({ device_id: cfg.device_id, pip_id: pipId }),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const maxItems = cfg.max_items || 12;
|
||||||
|
const sep = cfg.separator || ' • ';
|
||||||
|
const pollSec = cfg.poll_interval_sec || 300;
|
||||||
|
let currentPip = null;
|
||||||
|
|
||||||
|
console.log(`News ticker starting — feed=${cfg.feed_url}`);
|
||||||
|
console.log(` poll: every ${pollSec}s max headlines: ${maxItems} target: ${cfg.device_id}`);
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
let xml;
|
||||||
|
try {
|
||||||
|
const res = await fetch(cfg.feed_url, { headers: { Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' } });
|
||||||
|
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
|
||||||
|
xml = await res.text();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] feed fetch error: ${e.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headlines = parseHeadlines(xml, maxItems);
|
||||||
|
if (headlines.length === 0) { console.error(`[${new Date().toISOString()}] no headlines parsed`); return; }
|
||||||
|
const label = cfg.label || feedLabel(xml) || 'NEWS';
|
||||||
|
const text = headlines.join(sep);
|
||||||
|
const uri = buildOverlayUri(cfg.overlay_base_url, { text, label, sep });
|
||||||
|
try {
|
||||||
|
currentPip = await pipShow(cfg, uri);
|
||||||
|
console.log(`[${new Date().toISOString()}] SHOW ${headlines.length} headline(s) pip=${currentPip}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const timer = setInterval(tick, pollSec * 1000);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
clearInterval(timer);
|
||||||
|
if (currentPip) { console.log('\nclearing ticker before exit...'); await pipClear(cfg, currentPip); }
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { decodeText, firstTitle, parseHeadlines, feedLabel, buildOverlayUri };
|
||||||
12
Examples/PIP-News-Ticker/package.json
Normal file
12
Examples/PIP-News-Ticker/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-news-ticker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: scroll RSS/Atom headlines across a ScreenTinker screen via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "news.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node news.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
44
Examples/PIP-News-Ticker/test.js
Normal file
44
Examples/PIP-News-Ticker/test.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline test for the news-ticker parser. No network, no PiP push.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { parseHeadlines, feedLabel, decodeText, buildOverlayUri } = require('./news');
|
||||||
|
|
||||||
|
const xml = fs.readFileSync(path.join(__dirname, 'fixture-feed.xml'), 'utf8');
|
||||||
|
let pass = true;
|
||||||
|
const checks = [];
|
||||||
|
function check(name, cond, got) {
|
||||||
|
checks.push({ name, cond, got });
|
||||||
|
if (!cond) pass = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = parseHeadlines(xml, 12);
|
||||||
|
check('extracts all 5 items', all.length === 5, all.length);
|
||||||
|
check('order preserved (#1)', all[0] === 'City council approves new transit line', all[0]);
|
||||||
|
check('CDATA decoded + tags stripped', all[1] === 'Markets rally as tech shares climb', all[1]);
|
||||||
|
check('ampersand entity decoded', all[2] === 'Storms & flooding expected this weekend', all[2]);
|
||||||
|
check('numeric entity (–) decoded', all[3] === 'Local team wins championship 3–2', all[3]);
|
||||||
|
check('last item present', all[4] === 'Library extends weekend hours', all[4]);
|
||||||
|
|
||||||
|
const capped = parseHeadlines(xml, 3);
|
||||||
|
check('max_items caps the list', capped.length === 3, capped.length);
|
||||||
|
|
||||||
|
const label = feedLabel(xml);
|
||||||
|
check('channel title used as label', label === 'Demo Newsroom', label);
|
||||||
|
|
||||||
|
// decodeText: ampersand applied last so escaped entities survive
|
||||||
|
check('escaped < survives', decodeText('a &lt; b') === 'a < b', decodeText('a &lt; b'));
|
||||||
|
|
||||||
|
// uri round-trips through URLSearchParams
|
||||||
|
const uri = buildOverlayUri('https://signage.example.com/news-overlay.html', {
|
||||||
|
text: 'A • B & C', label: 'NEWS', sep: ' • ',
|
||||||
|
});
|
||||||
|
const parsed = new URLSearchParams(uri.split('?')[1]);
|
||||||
|
check('uri text round-trips', parsed.get('text') === 'A • B & C', parsed.get('text'));
|
||||||
|
check('uri label round-trips', parsed.get('label') === 'NEWS', parsed.get('label'));
|
||||||
|
check('uri uses ? join once', (uri.match(/\?/g) || []).length === 1, uri);
|
||||||
|
|
||||||
|
for (const c of checks) console.log(`${c.cond ? '✓' : '✗'} ${c.name}${c.cond ? '' : ` (got: ${JSON.stringify(c.got)})`}`);
|
||||||
|
console.log(`\nRESULT: ${pass ? 'PASS ✅' : 'FAIL ❌'}`);
|
||||||
|
process.exit(pass ? 0 : 1);
|
||||||
3
Examples/PIP-QR-Rotator/.gitignore
vendored
Normal file
3
Examples/PIP-QR-Rotator/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
110
Examples/PIP-QR-Rotator/README.md
Normal file
110
Examples/PIP-QR-Rotator/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# PiP QR Rotator
|
||||||
|
|
||||||
|
Rotate **scannable QR codes** through a corner of your ScreenTinker screens via the PiP
|
||||||
|
API — Guest Wi-Fi, the lunch menu, a feedback survey, a "scan to download" link, the event
|
||||||
|
schedule, a checkout/tip link… anything a phone camera should grab.
|
||||||
|
|
||||||
|
The QR codes are generated **client-side, in the overlay itself** — no QR web service, no
|
||||||
|
image hosting, no external libraries, no network calls. That keeps it fast, private, and
|
||||||
|
compliant with the player's Content-Security-Policy (`script-src 'self'`).
|
||||||
|
|
||||||
|
```
|
||||||
|
qr.js --(POST /api/pip, type:web)--> player
|
||||||
|
uri = qr-overlay.html?data=<payload>&label=<caption>
|
||||||
|
|
|
||||||
|
qr-overlay.js encodes <payload> into a QR matrix and paints it on a <canvas>
|
||||||
|
every rotate_interval_sec, qr.js pushes the next entry (player = last-show-wins)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `qr.js` | Rotates through `config.entries`, pushing each as a PiP overlay. `--clear` removes it. |
|
||||||
|
| `qr-overlay.html` / `qr-overlay.js` | The overlay page the player loads in an iframe. **Generates the QR client-side.** Must be served by your ScreenTinker host (same-origin with the player). |
|
||||||
|
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||||
|
| `test.js` | Offline unit test (`npm test`) — pure helpers + the QR encoder's Reed-Solomon core. |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Mint a token.** In the dashboard create an API token with the **`full`** scope (PiP
|
||||||
|
is fleet-affecting and renders web content, so it requires `full`).
|
||||||
|
|
||||||
|
2. **Serve the overlay assets.** Copy `qr-overlay.html` and `qr-overlay.js` into the
|
||||||
|
directory your ScreenTinker server serves at the web root (its `frontend/` dir), so they
|
||||||
|
live at `https://<your-host>/qr-overlay.html`. They **must** be same-origin with the
|
||||||
|
player — the server applies a CSP that only allows same-origin scripts, which is exactly
|
||||||
|
why the QR is drawn by `qr-overlay.js` (no CDN).
|
||||||
|
|
||||||
|
3. **Configure.** `cp config.example.json config.json` and set `api_base`, `api_token`,
|
||||||
|
`overlay_base_url` (the URL from step 2), `device_id` (a device **or** a group id), and
|
||||||
|
your `entries`.
|
||||||
|
|
||||||
|
4. **Run.** `node qr.js` — it pushes the first code immediately, then rotates every
|
||||||
|
`rotate_interval_sec`. `Ctrl-C` clears the overlay.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
|-----|---------|
|
||||||
|
| `entries` | Array of `{ label, data }`. `data` is the QR payload (required); `label` is the caption shown under it. |
|
||||||
|
| `rotate_interval_sec` | Seconds between entries (default `15`). A single entry just stays up. |
|
||||||
|
| `position` | `top-left`, `top-right`, `bottom-left`, `bottom-right` (default), or `center`. |
|
||||||
|
| `width` / `height` | Overlay box px (default `360` × `420` — tall so the caption fits under the code). |
|
||||||
|
| `border_radius`, `opacity` | Optional overlay styling. |
|
||||||
|
|
||||||
|
### QR payload cookbook
|
||||||
|
|
||||||
|
| Use | `data` value |
|
||||||
|
|-----|--------------|
|
||||||
|
| Open a link | `https://example.com/menu` |
|
||||||
|
| **Join Wi-Fi** (auto-connect) | `WIFI:T:WPA;S:<ssid>;P:<password>;;` — for an open network use `WIFI:T:nopass;S:<ssid>;;` |
|
||||||
|
| Pre-filled email | `mailto:hi@example.com?subject=Feedback` |
|
||||||
|
| Phone number | `tel:+15551234567` |
|
||||||
|
| Plain text | any text |
|
||||||
|
|
||||||
|
> Wi-Fi note: special characters in the SSID/password (`\ ; , : "`) must be backslash-escaped
|
||||||
|
> per the Wi-Fi QR spec, e.g. `P:p\;w\:d`.
|
||||||
|
|
||||||
|
## Local quick-start (this repo)
|
||||||
|
|
||||||
|
The local ScreenTinker instance serves on `https://localhost:3443/` (self-signed) and the
|
||||||
|
registered player is device `DEVICE_OR_GROUP_ID`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the repo root: serve the overlay assets same-origin with the player
|
||||||
|
cp Examples/PIP-QR-Rotator/qr-overlay.html Examples/PIP-QR-Rotator/qr-overlay.js frontend/
|
||||||
|
|
||||||
|
# then in this dir:
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json:
|
||||||
|
# "api_base": "https://localhost:3443/"
|
||||||
|
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
|
||||||
|
# "overlay_base_url": "https://localhost:3443/qr-overlay.html"
|
||||||
|
# "device_id": "DEVICE_OR_GROUP_ID"
|
||||||
|
|
||||||
|
# self-signed cert -> let Node accept it for this run
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node qr.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs offline (no network, no player): validates the rotation/URL helpers and verifies the
|
||||||
|
embedded QR encoder's Reed-Solomon math against the published QR generator polynomials, plus
|
||||||
|
structural checks (finder/timing patterns, version sizing). For the real proof, point it at
|
||||||
|
a screen and **scan it with your phone**.
|
||||||
|
|
||||||
|
## Notes & limits
|
||||||
|
|
||||||
|
- The encoder is a compact **byte-mode** implementation of the QR spec (ISO/IEC 18004),
|
||||||
|
based on Nayuki's reference algorithm (MIT). Byte mode handles any UTF-8 payload; it
|
||||||
|
auto-selects the smallest version and the best mask, and boosts the error-correction level
|
||||||
|
for free when there's spare capacity (more robust scanning).
|
||||||
|
- Keep payloads reasonably short for at-a-distance scanning — long URLs make a denser code.
|
||||||
|
Use a link shortener for long destinations.
|
||||||
|
- Like all PiP overlays, this is **ephemeral**: a player reboot drops it (re-run to restore),
|
||||||
|
and the script clears it on `Ctrl-C`.
|
||||||
18
Examples/PIP-QR-Rotator/config.example.json
Normal file
18
Examples/PIP-QR-Rotator/config.example.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/qr-overlay.html",
|
||||||
|
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"rotate_interval_sec": 15,
|
||||||
|
"position": "bottom-right",
|
||||||
|
"width": 360,
|
||||||
|
"height": 420,
|
||||||
|
|
||||||
|
"entries": [
|
||||||
|
{ "label": "Guest Wi-Fi", "data": "WIFI:T:WPA;S:Lobby-Guest;P:welcome123;;" },
|
||||||
|
{ "label": "Today's Lunch Menu", "data": "https://example.com/menu" },
|
||||||
|
{ "label": "Tell us how we're doing", "data": "https://example.com/survey" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
Examples/PIP-QR-Rotator/package.json
Normal file
12
Examples/PIP-QR-Rotator/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-qr-rotator",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: rotate scannable QR codes (Wi-Fi, menu, survey, links) on ScreenTinker screens via the PiP API. QR codes are generated client-side — no network, no dependencies.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "qr.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node qr.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
32
Examples/PIP-QR-Rotator/qr-overlay.html
Normal file
32
Examples/PIP-QR-Rotator/qr-overlay.html
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Scan Me</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 16px; background: #14161c; color: #fff; border-radius: 16px; overflow: hidden;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 22px; box-sizing: border-box; }
|
||||||
|
.panel { background: #fff; border-radius: 14px; padding: 14px; line-height: 0;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.25); }
|
||||||
|
.panel canvas { display: block; width: clamp(160px, 46vh, 360px); height: auto; image-rendering: pixelated; }
|
||||||
|
.label { font-size: clamp(16px, 4.5vw, 28px); font-weight: 700; text-align: center; line-height: 1.15;
|
||||||
|
max-width: 95%; }
|
||||||
|
.hint { font-size: clamp(11px, 2.6vw, 15px); color: #9aa0aa; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.placeholder { color: #9aa0aa; font-size: clamp(14px, 4vw, 22px); text-align: center; padding: 30px;
|
||||||
|
display: flex; align-items: center; justify-content: center; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="panel"><canvas id="qr"></canvas></div>
|
||||||
|
<div class="label" id="label"></div>
|
||||||
|
<div class="hint">📷 Scan with your camera</div>
|
||||||
|
<div class="placeholder" id="placeholder" style="display:none">No QR data</div>
|
||||||
|
</div>
|
||||||
|
<script src="qr-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
361
Examples/PIP-QR-Rotator/qr-overlay.js
Normal file
361
Examples/PIP-QR-Rotator/qr-overlay.js
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
// QR Rotator overlay — generates the QR code CLIENT-SIDE, with NO network calls and NO
|
||||||
|
// external libraries, so it satisfies the player's CSP (scriptSrc 'self') and works
|
||||||
|
// fully offline. Reads ?data (the QR payload) and ?label (caption) from the URL.
|
||||||
|
//
|
||||||
|
// The encoder is a compact byte-mode implementation of the QR Code spec (ISO/IEC 18004),
|
||||||
|
// based on Nayuki's "QR Code generator" reference algorithm (MIT License). Byte mode is
|
||||||
|
// used for everything, so any UTF-8 payload works (URLs, WIFI: strings, plain text).
|
||||||
|
//
|
||||||
|
// It also exports its internals via module.exports when require()'d in Node, so the
|
||||||
|
// offline test can verify the Reed-Solomon / encoder core without needing a decoder.
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ---------- GF(256) arithmetic & Reed-Solomon (Nayuki) ----------
|
||||||
|
function rsMul(x, y) {
|
||||||
|
var z = 0;
|
||||||
|
for (var i = 7; i >= 0; i--) {
|
||||||
|
z = (z << 1) ^ ((z >>> 7) * 0x11D);
|
||||||
|
z ^= ((y >>> i) & 1) * x;
|
||||||
|
}
|
||||||
|
return z & 0xFF;
|
||||||
|
}
|
||||||
|
function rsDivisor(degree) {
|
||||||
|
if (degree < 1 || degree > 255) throw new RangeError('degree out of range');
|
||||||
|
var result = [];
|
||||||
|
for (var i = 0; i < degree - 1; i++) result.push(0);
|
||||||
|
result.push(1);
|
||||||
|
var root = 1;
|
||||||
|
for (i = 0; i < degree; i++) {
|
||||||
|
for (var j = 0; j < result.length; j++) {
|
||||||
|
result[j] = rsMul(result[j], root);
|
||||||
|
if (j + 1 < result.length) result[j] ^= result[j + 1];
|
||||||
|
}
|
||||||
|
root = rsMul(root, 0x02);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function rsRemainder(data, divisor) {
|
||||||
|
var result = divisor.map(function () { return 0; });
|
||||||
|
for (var k = 0; k < data.length; k++) {
|
||||||
|
var factor = data[k] ^ result.shift();
|
||||||
|
result.push(0);
|
||||||
|
for (var i = 0; i < divisor.length; i++) result[i] ^= rsMul(divisor[i], factor);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- spec tables: [ecl 0..3 = L,M,Q,H][version 1..40] ----------
|
||||||
|
var ECC_CW = [
|
||||||
|
[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],
|
||||||
|
[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],
|
||||||
|
[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],
|
||||||
|
[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]
|
||||||
|
];
|
||||||
|
var ECC_BLOCKS = [
|
||||||
|
[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],
|
||||||
|
[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],
|
||||||
|
[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],
|
||||||
|
[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]
|
||||||
|
];
|
||||||
|
var ECL_FORMAT = [1, 0, 3, 2]; // 2-bit format value for L,M,Q,H
|
||||||
|
var ECL_INDEX = { L: 0, M: 1, Q: 2, H: 3 };
|
||||||
|
|
||||||
|
function numRawDataModules(ver) {
|
||||||
|
var result = (16 * ver + 128) * ver + 64;
|
||||||
|
if (ver >= 2) {
|
||||||
|
var numAlign = Math.floor(ver / 7) + 2;
|
||||||
|
result -= (25 * numAlign - 10) * numAlign - 55;
|
||||||
|
if (ver >= 7) result -= 36;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function numDataCodewords(ver, ecl) {
|
||||||
|
return Math.floor(numRawDataModules(ver) / 8) - ECC_CW[ecl][ver] * ECC_BLOCKS[ecl][ver];
|
||||||
|
}
|
||||||
|
function alignmentPositions(ver) {
|
||||||
|
if (ver === 1) return [];
|
||||||
|
var numAlign = Math.floor(ver / 7) + 2;
|
||||||
|
var step = (ver === 32) ? 26 : Math.ceil((ver * 4 + 4) / (numAlign * 2 - 2)) * 2;
|
||||||
|
var size = ver * 4 + 17;
|
||||||
|
var result = [6];
|
||||||
|
for (var pos = size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function getBit(x, i) { return ((x >>> i) & 1) !== 0; }
|
||||||
|
|
||||||
|
// UTF-8 bytes for a string, dependency-free (TextEncoder when present).
|
||||||
|
function utf8Bytes(str) {
|
||||||
|
if (typeof TextEncoder !== 'undefined') return Array.from(new TextEncoder().encode(str));
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
var c = str.charCodeAt(i);
|
||||||
|
if (c < 0x80) out.push(c);
|
||||||
|
else if (c < 0x800) { out.push(0xC0 | (c >> 6), 0x80 | (c & 0x3F)); }
|
||||||
|
else { out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- encode bytes -> { size, modules } ----------
|
||||||
|
function encodeBytes(dataBytes, eclName) {
|
||||||
|
var ecl = ECL_INDEX[eclName] != null ? ECL_INDEX[eclName] : 1;
|
||||||
|
|
||||||
|
// smallest version that fits
|
||||||
|
var ver;
|
||||||
|
for (ver = 1; ; ver++) {
|
||||||
|
if (ver > 40) throw new RangeError('Data too long to fit in any QR version');
|
||||||
|
var ccbits = ver <= 9 ? 8 : 16;
|
||||||
|
var usedBits = 4 + ccbits + dataBytes.length * 8;
|
||||||
|
if (usedBits <= numDataCodewords(ver, ecl) * 8) break;
|
||||||
|
}
|
||||||
|
// boost ECC level for free if it still fits at this version
|
||||||
|
[1, 2, 3].forEach(function (newEcl) {
|
||||||
|
var ccbits = ver <= 9 ? 8 : 16;
|
||||||
|
var usedBits = 4 + ccbits + dataBytes.length * 8;
|
||||||
|
if (newEcl > ecl && usedBits <= numDataCodewords(ver, newEcl) * 8) ecl = newEcl;
|
||||||
|
});
|
||||||
|
|
||||||
|
// build bit buffer
|
||||||
|
var bb = [];
|
||||||
|
function appendBits(val, len) { for (var i = len - 1; i >= 0; i--) bb.push((val >>> i) & 1); }
|
||||||
|
appendBits(0x4, 4); // byte mode indicator
|
||||||
|
appendBits(dataBytes.length, ver <= 9 ? 8 : 16); // char count
|
||||||
|
for (var i = 0; i < dataBytes.length; i++) appendBits(dataBytes[i], 8);
|
||||||
|
|
||||||
|
var capacityBits = numDataCodewords(ver, ecl) * 8;
|
||||||
|
appendBits(0, Math.min(4, capacityBits - bb.length)); // terminator
|
||||||
|
appendBits(0, (8 - bb.length % 8) % 8); // byte align
|
||||||
|
for (var pad = 0xEC; bb.length < capacityBits; pad ^= 0xEC ^ 0x11) appendBits(pad, 8);
|
||||||
|
|
||||||
|
var dataCodewords = [];
|
||||||
|
for (i = 0; i < bb.length; i += 8) {
|
||||||
|
var b = 0;
|
||||||
|
for (var j = 0; j < 8; j++) b = (b << 1) | bb[i + j];
|
||||||
|
dataCodewords.push(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCodewords = addEccAndInterleave(dataCodewords, ver, ecl);
|
||||||
|
return buildMatrix(allCodewords, ver, ecl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEccAndInterleave(data, ver, ecl) {
|
||||||
|
var numBlocks = ECC_BLOCKS[ecl][ver];
|
||||||
|
var blockEccLen = ECC_CW[ecl][ver];
|
||||||
|
var rawCodewords = Math.floor(numRawDataModules(ver) / 8);
|
||||||
|
var numShortBlocks = numBlocks - rawCodewords % numBlocks;
|
||||||
|
var shortBlockLen = Math.floor(rawCodewords / numBlocks);
|
||||||
|
var blocks = [];
|
||||||
|
var divisor = rsDivisor(blockEccLen);
|
||||||
|
for (var i = 0, k = 0; i < numBlocks; i++) {
|
||||||
|
var dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
|
||||||
|
k += dat.length;
|
||||||
|
var ecc = rsRemainder(dat, divisor);
|
||||||
|
if (i < numShortBlocks) dat = dat.concat([0]);
|
||||||
|
blocks.push(dat.concat(ecc));
|
||||||
|
}
|
||||||
|
var result = [];
|
||||||
|
for (i = 0; i < blocks[0].length; i++) {
|
||||||
|
for (var j = 0; j < blocks.length; j++) {
|
||||||
|
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(blocks[j][i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatrix(allCodewords, ver, ecl) {
|
||||||
|
var size = ver * 4 + 17;
|
||||||
|
var modules = [], isFunc = [];
|
||||||
|
for (var i = 0; i < size; i++) { modules.push(new Array(size).fill(false)); isFunc.push(new Array(size).fill(false)); }
|
||||||
|
function set(x, y, dark) { if (x >= 0 && x < size && y >= 0 && y < size) { modules[y][x] = dark; isFunc[y][x] = true; } }
|
||||||
|
|
||||||
|
// timing patterns
|
||||||
|
for (i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||||
|
// finder patterns + separators
|
||||||
|
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(function (c) {
|
||||||
|
for (var dy = -4; dy <= 4; dy++) for (var dx = -4; dx <= 4; dx++) {
|
||||||
|
var dist = Math.max(Math.abs(dx), Math.abs(dy));
|
||||||
|
set(c[0] + dx, c[1] + dy, dist !== 2 && dist !== 4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// alignment patterns
|
||||||
|
var ap = alignmentPositions(ver), n = ap.length;
|
||||||
|
for (i = 0; i < n; i++) for (var j = 0; j < n; j++) {
|
||||||
|
if ((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0)) continue;
|
||||||
|
for (var dy = -2; dy <= 2; dy++) for (var dx = -2; dx <= 2; dx++) {
|
||||||
|
set(ap[j] + dx, ap[i] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFormat(mask) {
|
||||||
|
var data = (ECL_FORMAT[ecl] << 3) | mask;
|
||||||
|
var rem = data;
|
||||||
|
for (var i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
||||||
|
var bits = ((data << 10) | rem) ^ 0x5412;
|
||||||
|
for (i = 0; i <= 5; i++) set(8, i, getBit(bits, i));
|
||||||
|
set(8, 7, getBit(bits, 6)); set(8, 8, getBit(bits, 7)); set(7, 8, getBit(bits, 8));
|
||||||
|
for (i = 9; i < 15; i++) set(14 - i, 8, getBit(bits, i));
|
||||||
|
for (i = 0; i < 8; i++) set(size - 1 - i, 8, getBit(bits, i));
|
||||||
|
for (i = 8; i < 15; i++) set(8, size - 15 + i, getBit(bits, i));
|
||||||
|
set(8, size - 8, true); // always-dark module
|
||||||
|
}
|
||||||
|
function drawVersion() {
|
||||||
|
if (ver < 7) return;
|
||||||
|
var rem = ver;
|
||||||
|
for (var i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
|
||||||
|
var bits = (ver << 12) | rem;
|
||||||
|
for (i = 0; i < 18; i++) {
|
||||||
|
var bit = getBit(bits, i);
|
||||||
|
var a = size - 11 + i % 3, b = Math.floor(i / 3);
|
||||||
|
set(a, b, bit); set(b, a, bit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drawFormat(0); // reserve the format areas as function modules
|
||||||
|
drawVersion();
|
||||||
|
|
||||||
|
// draw data + ecc codewords (zigzag, bottom-right -> up)
|
||||||
|
var bitIdx = 0;
|
||||||
|
for (var right = size - 1; right >= 1; right -= 2) {
|
||||||
|
if (right === 6) right = 5;
|
||||||
|
for (var vert = 0; vert < size; vert++) {
|
||||||
|
for (var c2 = 0; c2 < 2; c2++) {
|
||||||
|
var x = right - c2;
|
||||||
|
var upward = ((right + 1) & 2) === 0;
|
||||||
|
var y = upward ? size - 1 - vert : vert;
|
||||||
|
if (!isFunc[y][x] && bitIdx < allCodewords.length * 8) {
|
||||||
|
modules[y][x] = getBit(allCodewords[bitIdx >>> 3], 7 - (bitIdx & 7));
|
||||||
|
bitIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// choose the mask with the lowest penalty, then apply it for real
|
||||||
|
function applyMask(mask) {
|
||||||
|
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) {
|
||||||
|
if (isFunc[y][x]) continue;
|
||||||
|
var invert;
|
||||||
|
switch (mask) {
|
||||||
|
case 0: invert = (x + y) % 2 === 0; break;
|
||||||
|
case 1: invert = y % 2 === 0; break;
|
||||||
|
case 2: invert = x % 3 === 0; break;
|
||||||
|
case 3: invert = (x + y) % 3 === 0; break;
|
||||||
|
case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
|
||||||
|
case 5: invert = (x * y) % 2 + (x * y) % 3 === 0; break;
|
||||||
|
case 6: invert = ((x * y) % 2 + (x * y) % 3) % 2 === 0; break;
|
||||||
|
case 7: invert = ((x + y) % 2 + (x * y) % 3) % 2 === 0; break;
|
||||||
|
}
|
||||||
|
if (invert) modules[y][x] = !modules[y][x];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var best = -1, minPenalty = Infinity;
|
||||||
|
for (var mask = 0; mask < 8; mask++) {
|
||||||
|
drawFormat(mask); applyMask(mask);
|
||||||
|
var p = penalty(modules, size);
|
||||||
|
if (p < minPenalty) { minPenalty = p; best = mask; }
|
||||||
|
applyMask(mask); // undo (XOR is its own inverse)
|
||||||
|
}
|
||||||
|
drawFormat(best); applyMask(best);
|
||||||
|
|
||||||
|
return { size: size, modules: modules, version: ver, ecl: ecl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- mask penalty (Nayuki getPenaltyScore) ----------
|
||||||
|
function penalty(modules, size) {
|
||||||
|
var N1 = 3, N2 = 3, N3 = 40, N4 = 10, result = 0;
|
||||||
|
|
||||||
|
function countPatterns(rh) {
|
||||||
|
var nn = rh[1];
|
||||||
|
var core = nn > 0 && rh[2] === nn && rh[3] === nn * 3 && rh[4] === nn && rh[5] === nn;
|
||||||
|
return (core && rh[0] >= nn * 4 && rh[6] >= nn ? 1 : 0) + (core && rh[6] >= nn * 4 && rh[0] >= nn ? 1 : 0);
|
||||||
|
}
|
||||||
|
function addHistory(run, rh) { if (rh[0] === 0) run += size; rh.pop(); rh.unshift(run); }
|
||||||
|
function terminate(color, run, rh) {
|
||||||
|
if (color) { addHistory(run, rh); run = 0; }
|
||||||
|
run += size; addHistory(run, rh);
|
||||||
|
return countPatterns(rh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rows
|
||||||
|
for (var y = 0; y < size; y++) {
|
||||||
|
var color = false, run = 0, rh = [0, 0, 0, 0, 0, 0, 0];
|
||||||
|
for (var x = 0; x < size; x++) {
|
||||||
|
if (modules[y][x] === color) { run++; if (run === 5) result += N1; else if (run > 5) result++; }
|
||||||
|
else { addHistory(run, rh); if (!color) result += countPatterns(rh) * N3; color = modules[y][x]; run = 1; }
|
||||||
|
}
|
||||||
|
result += terminate(color, run, rh) * N3;
|
||||||
|
}
|
||||||
|
// columns
|
||||||
|
for (var x2 = 0; x2 < size; x2++) {
|
||||||
|
var color2 = false, run2 = 0, rh2 = [0, 0, 0, 0, 0, 0, 0];
|
||||||
|
for (var y2 = 0; y2 < size; y2++) {
|
||||||
|
if (modules[y2][x2] === color2) { run2++; if (run2 === 5) result += N1; else if (run2 > 5) result++; }
|
||||||
|
else { addHistory(run2, rh2); if (!color2) result += countPatterns(rh2) * N3; color2 = modules[y2][x2]; run2 = 1; }
|
||||||
|
}
|
||||||
|
result += terminate(color2, run2, rh2) * N3;
|
||||||
|
}
|
||||||
|
// 2x2 blocks
|
||||||
|
for (var yy = 0; yy < size - 1; yy++) for (var xx = 0; xx < size - 1; xx++) {
|
||||||
|
var c = modules[yy][xx];
|
||||||
|
if (c === modules[yy][xx + 1] && c === modules[yy + 1][xx] && c === modules[yy + 1][xx + 1]) result += N2;
|
||||||
|
}
|
||||||
|
// dark proportion
|
||||||
|
var dark = 0;
|
||||||
|
for (var a = 0; a < size; a++) for (var b = 0; b < size; b++) if (modules[a][b]) dark++;
|
||||||
|
var total = size * size;
|
||||||
|
var k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
|
||||||
|
result += k * N4;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
var QR = { rsMul: rsMul, rsDivisor: rsDivisor, rsRemainder: rsRemainder, encodeBytes: encodeBytes, utf8Bytes: utf8Bytes, numDataCodewords: numDataCodewords };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = QR;
|
||||||
|
else global.QR = QR;
|
||||||
|
|
||||||
|
// ---------- browser rendering ----------
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var data = q.get('data') || '';
|
||||||
|
var label = (q.get('label') || '').trim();
|
||||||
|
|
||||||
|
var labelEl = document.getElementById('label');
|
||||||
|
if (labelEl) labelEl.textContent = label;
|
||||||
|
|
||||||
|
var canvas = document.getElementById('qr');
|
||||||
|
var placeholder = document.getElementById('placeholder');
|
||||||
|
|
||||||
|
if (!data) { show(placeholder); hide(canvas); return; }
|
||||||
|
try {
|
||||||
|
var qr = encodeBytes(utf8Bytes(data), 'M');
|
||||||
|
paint(canvas, qr);
|
||||||
|
show(canvas); hide(placeholder);
|
||||||
|
} catch (e) {
|
||||||
|
if (placeholder) placeholder.textContent = 'QR error: ' + (e && e.message ? e.message : e);
|
||||||
|
show(placeholder); hide(canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function show(el) { if (el) el.style.display = ''; }
|
||||||
|
function hide(el) { if (el) el.style.display = 'none'; }
|
||||||
|
|
||||||
|
function paint(canvas, qr) {
|
||||||
|
if (!canvas) return;
|
||||||
|
var quiet = 4;
|
||||||
|
var dim = qr.size + quiet * 2;
|
||||||
|
var scale = Math.max(2, Math.floor(560 / dim)); // crisp internal resolution
|
||||||
|
canvas.width = dim * scale;
|
||||||
|
canvas.height = dim * scale;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
for (var y = 0; y < qr.size; y++) for (var x = 0; x < qr.size; x++) {
|
||||||
|
if (qr.modules[y][x]) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', draw);
|
||||||
|
else draw();
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
153
Examples/PIP-QR-Rotator/qr.js
Normal file
153
Examples/PIP-QR-Rotator/qr.js
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// QR Rotator -> ScreenTinker PiP. Cycles through a list of {label, data} entries,
|
||||||
|
// pushing each as a PiP web overlay that renders the QR code CLIENT-SIDE (the encoder
|
||||||
|
// lives in qr-overlay.js — no network, no external libraries, CSP-safe). Every
|
||||||
|
// `rotate_interval_sec` it shows the next entry; the player keeps a single overlay slot
|
||||||
|
// (last-show-wins) so each push replaces the previous one. Cleared on exit.
|
||||||
|
//
|
||||||
|
// node qr.js [path/to/config.json]
|
||||||
|
// node qr.js [config] --clear # remove the overlay and exit
|
||||||
|
//
|
||||||
|
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||||
|
//
|
||||||
|
// Good for: guest Wi-Fi join, lunch menu, feedback survey, ticket/checkout links,
|
||||||
|
// "scan to download the app", event schedule — anything a phone camera should grab.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// --- pure, testable helpers (no I/O) ---
|
||||||
|
|
||||||
|
// Keep only well-formed entries: `data` is required (the QR payload); `label` is
|
||||||
|
// optional caption text. Returns { entries, errors } so the caller can warn and proceed.
|
||||||
|
function validateEntries(raw) {
|
||||||
|
const entries = [];
|
||||||
|
const errors = [];
|
||||||
|
if (!Array.isArray(raw)) return { entries, errors: ['"entries" must be an array'] };
|
||||||
|
raw.forEach((e, i) => {
|
||||||
|
if (!e || typeof e !== 'object') { errors.push(`entry ${i}: not an object`); return; }
|
||||||
|
const data = typeof e.data === 'string' ? e.data.trim() : '';
|
||||||
|
if (!data) { errors.push(`entry ${i}: missing "data"`); return; }
|
||||||
|
entries.push({ label: typeof e.label === 'string' ? e.label : '', data });
|
||||||
|
});
|
||||||
|
return { entries, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the overlay URL with the QR payload + caption in the query string.
|
||||||
|
function overlayUri(overlayBase, entry) {
|
||||||
|
const q = new URLSearchParams({ data: entry.data || '', label: entry.label || '' });
|
||||||
|
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the rotation index, wrapping around the list.
|
||||||
|
function nextIndex(i, len) {
|
||||||
|
if (!len || len < 1) return 0;
|
||||||
|
return (i + 1) % len;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateEntries, overlayUri, nextIndex };
|
||||||
|
|
||||||
|
// --- CLI ---
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const clear = args.includes('--clear');
|
||||||
|
const positional = args.filter(a => !a.startsWith('--'));
|
||||||
|
const cfgPath = positional[0] || path.join(__dirname, 'config.json');
|
||||||
|
|
||||||
|
let cfg;
|
||||||
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
||||||
|
catch (e) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
|
||||||
|
|
||||||
|
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
|
||||||
|
const apiToken = cfg.api_token;
|
||||||
|
const overlayBase = cfg.overlay_base_url;
|
||||||
|
const deviceId = cfg.device_id;
|
||||||
|
|
||||||
|
if (!apiBase || !apiToken || !deviceId) {
|
||||||
|
console.error('config must set api_base, api_token, and device_id.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (clear) return doClear(apiBase, apiToken, deviceId);
|
||||||
|
|
||||||
|
if (!overlayBase) { console.error('config must set overlay_base_url (where qr-overlay.html is served).'); process.exit(1); }
|
||||||
|
|
||||||
|
const { entries, errors } = validateEntries(cfg.entries);
|
||||||
|
for (const err of errors) console.warn(`skipping ${err}`);
|
||||||
|
if (entries.length === 0) { console.error('config.entries has no valid entries (each needs a "data" string).'); process.exit(1); }
|
||||||
|
|
||||||
|
const intervalSec = cfg.rotate_interval_sec || 15;
|
||||||
|
const position = cfg.position || 'bottom-right';
|
||||||
|
const width = cfg.width || 360;
|
||||||
|
const height = cfg.height || 420;
|
||||||
|
const opacity = cfg.opacity != null ? cfg.opacity : 1;
|
||||||
|
const borderRadius = cfg.border_radius != null ? cfg.border_radius : 16;
|
||||||
|
|
||||||
|
console.log(`QR rotator starting — ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, every ${intervalSec}s, position ${position}`);
|
||||||
|
entries.forEach((e, i) => console.log(` ${i + 1}. ${e.label || '(no label)'} -> ${e.data.slice(0, 60)}${e.data.length > 60 ? '…' : ''}`));
|
||||||
|
|
||||||
|
const opts = { apiBase, apiToken, deviceId, overlayBase, position, width, height, opacity, borderRadius };
|
||||||
|
let idx = 0;
|
||||||
|
let lastPip = null;
|
||||||
|
|
||||||
|
async function show() {
|
||||||
|
const entry = entries[idx];
|
||||||
|
try {
|
||||||
|
lastPip = await pipShow(opts, entry);
|
||||||
|
console.log(`[${new Date().toISOString()}] SHOW ${idx + 1}/${entries.length} "${entry.label || '(no label)'}" pip=${lastPip}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
|
||||||
|
}
|
||||||
|
idx = nextIndex(idx, entries.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
show();
|
||||||
|
const timer = entries.length > 1 ? setInterval(show, intervalSec * 1000) : null;
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
console.log('\nclearing overlay before exit...');
|
||||||
|
try { await doClear(apiBase, apiToken, deviceId, true); } catch { /* best effort */ }
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipShow(opts, entry) {
|
||||||
|
const body = {
|
||||||
|
device_id: opts.deviceId,
|
||||||
|
type: 'web',
|
||||||
|
uri: overlayUri(opts.overlayBase, entry),
|
||||||
|
position: opts.position,
|
||||||
|
width: opts.width,
|
||||||
|
height: opts.height,
|
||||||
|
duration: 0, // persistent; we replace/clear it ourselves
|
||||||
|
opacity: opts.opacity,
|
||||||
|
border_radius: opts.borderRadius,
|
||||||
|
close_button: false,
|
||||||
|
title: (entry.label || '').slice(0, 200),
|
||||||
|
};
|
||||||
|
const res = await fetch(`${opts.apiBase}/api/pip`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiToken}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||||
|
return json.pip_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doClear(apiBase, apiToken, deviceId, quiet) {
|
||||||
|
const res = await fetch(`${apiBase}/api/pip/clear`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
||||||
|
body: JSON.stringify({ device_id: deviceId }),
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||||
|
if (!quiet) console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
|
||||||
|
}
|
||||||
72
Examples/PIP-QR-Rotator/test.js
Normal file
72
Examples/PIP-QR-Rotator/test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline test. No network, no player. Covers:
|
||||||
|
// - qr.js pure helpers (entry validation, overlay-uri build/round-trip, rotation wrap)
|
||||||
|
// - the embedded QR encoder's Reed-Solomon core, checked against the published QR
|
||||||
|
// generator polynomials (degree 7 and 10) — this catches GF(256) math errors without
|
||||||
|
// needing a QR decoder — plus structural invariants of a generated matrix.
|
||||||
|
const { validateEntries, overlayUri, nextIndex } = require('./qr');
|
||||||
|
const QR = require('./qr-overlay');
|
||||||
|
|
||||||
|
let ok = true;
|
||||||
|
function check(name, cond) { console.log(`${cond ? '•' : '✗'} ${name}`); if (!cond) ok = false; }
|
||||||
|
|
||||||
|
// ---- qr.js pure helpers ----
|
||||||
|
const v = validateEntries([
|
||||||
|
{ label: 'A', data: 'https://x.test/1' },
|
||||||
|
{ label: 'B', data: ' ' }, // blank -> rejected
|
||||||
|
{ data: 'WIFI:T:WPA;S:Net;P:pw;;' }, // no label -> ok, label defaults to ''
|
||||||
|
{ label: 'C' }, // no data -> rejected
|
||||||
|
]);
|
||||||
|
check('validateEntries keeps the 2 valid entries', v.entries.length === 2);
|
||||||
|
check('validateEntries reports the 2 bad entries', v.errors.length === 2);
|
||||||
|
check('validateEntries defaults missing label to ""', v.entries[1].label === '');
|
||||||
|
check('validateEntries non-array -> error', validateEntries('nope').errors.length === 1);
|
||||||
|
|
||||||
|
const entry = { label: 'Guest Wi-Fi & More', data: 'WIFI:T:WPA;S:Lobby Guest;P:p@ss=1;;' };
|
||||||
|
const uri = overlayUri('https://s.example.com/qr-overlay.html', entry);
|
||||||
|
const back = new URLSearchParams(uri.split('?')[1]);
|
||||||
|
check('overlayUri round-trips data exactly', back.get('data') === entry.data);
|
||||||
|
check('overlayUri round-trips label exactly', back.get('label') === entry.label);
|
||||||
|
check('overlayUri encodes (no raw spaces/&/;)', !/[ &;]/.test(uri.split('?')[1].replace(/&data=|&label=/, '')));
|
||||||
|
check('overlayUri joins with & when base already has ?',
|
||||||
|
overlayUri('https://s/x?a=1', { data: 'd' }).includes('?a=1&'));
|
||||||
|
|
||||||
|
check('nextIndex wraps around', nextIndex(2, 3) === 0 && nextIndex(0, 3) === 1 && nextIndex(1, 3) === 2);
|
||||||
|
check('nextIndex guards empty list', nextIndex(0, 0) === 0);
|
||||||
|
|
||||||
|
// ---- Reed-Solomon core vs published QR generator polynomials ----
|
||||||
|
// Build GF(256) exp/log tables from the encoder's own multiply, then convert the computed
|
||||||
|
// divisor coefficients back to alpha-exponent form to compare with the spec's values.
|
||||||
|
const exp = new Array(256), log = new Array(256);
|
||||||
|
exp[0] = 1;
|
||||||
|
for (let i = 1; i < 256; i++) exp[i] = QR.rsMul(exp[i - 1], 2);
|
||||||
|
for (let i = 0; i < 255; i++) log[exp[i]] = i;
|
||||||
|
|
||||||
|
function toAlpha(coeffs) { return coeffs.map((c) => log[c]); }
|
||||||
|
|
||||||
|
// Published non-leading generator-polynomial exponents (Thonky / ISO 18004 Annex A).
|
||||||
|
const GEN7 = [87, 229, 146, 149, 238, 102, 21];
|
||||||
|
const GEN10 = [251, 67, 46, 61, 118, 70, 64, 94, 32, 45];
|
||||||
|
const d7 = toAlpha(QR.rsDivisor(7));
|
||||||
|
const d10 = toAlpha(QR.rsDivisor(10));
|
||||||
|
check('RS generator poly (deg 7) matches spec', JSON.stringify(d7) === JSON.stringify(GEN7));
|
||||||
|
check('RS generator poly (deg 10) matches spec', JSON.stringify(d10) === JSON.stringify(GEN10));
|
||||||
|
|
||||||
|
// ---- encoder structural invariants ----
|
||||||
|
const tiny = QR.encodeBytes(QR.utf8Bytes('hi'), 'M'); // tiny -> version 1
|
||||||
|
check('tiny payload -> 21x21 (version 1)', tiny.size === 21 && tiny.modules.length === 21);
|
||||||
|
// finder patterns: dark outer ring at the three corners, white separator beside them.
|
||||||
|
check('top-left finder corner dark', tiny.modules[0][0] === true);
|
||||||
|
check('top-left separator light', tiny.modules[0][7] === false);
|
||||||
|
check('top-left finder centre dark', tiny.modules[3][3] === true);
|
||||||
|
check('top-right finder present', tiny.modules[0][tiny.size - 1] === true);
|
||||||
|
check('bottom-left finder present', tiny.modules[tiny.size - 1][0] === true);
|
||||||
|
// timing pattern alternates along row/col 6
|
||||||
|
check('timing pattern alternates', tiny.modules[6][8] !== tiny.modules[6][9]);
|
||||||
|
|
||||||
|
const url = QR.encodeBytes(QR.utf8Bytes('https://example.com/menu'), 'M');
|
||||||
|
check('longer URL bumps the version (size > 21)', url.size > 21 && (url.size - 17) % 4 === 0);
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
3
Examples/PIP-Room-Status-Calendar/.gitignore
vendored
Normal file
3
Examples/PIP-Room-Status-Calendar/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
107
Examples/PIP-Room-Status-Calendar/README.md
Normal file
107
Examples/PIP-Room-Status-Calendar/README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Room Status sign (calendar-driven Available / Busy)
|
||||||
|
|
||||||
|
Turns a ScreenTinker display into a meeting-room sign. It polls an **ICS calendar
|
||||||
|
feed** and pushes a [PiP](../../docs) web overlay that shows **AVAILABLE** (green) or
|
||||||
|
**BUSY** (red) plus the current/next meeting time. Re-pushed every poll so the state
|
||||||
|
stays fresh; cleared when you stop the script.
|
||||||
|
|
||||||
|
No dependencies — Node 18+ only.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
```
|
||||||
|
ICS feed ──poll──> room.js ──POST /api/pip (type=web)──> player renders room-overlay.html
|
||||||
|
```
|
||||||
|
|
||||||
|
- `room.js` fetches the calendar, parses VEVENTs, and decides busy/free at *now*.
|
||||||
|
- The overlay is `room-overlay.html` + `room-overlay.js`, served by the signage server
|
||||||
|
and rendered by the player in an iframe. The script reads the status from the URL
|
||||||
|
query string (the server CSP forbids inline scripts, so the logic lives in the
|
||||||
|
external `.js`).
|
||||||
|
|
||||||
|
## Get an ICS URL
|
||||||
|
|
||||||
|
- **Google Calendar:** Calendar settings → *Integrate calendar* → **Secret address in
|
||||||
|
iCal format**. (Treat it like a password.) For a room, use the room/resource calendar.
|
||||||
|
- **Outlook / Microsoft 365:** Calendar → Share → **Publish**, then copy the **ICS** link.
|
||||||
|
- Any CalDAV/ICS publisher works. The feed must be reachable by the machine running `room.js`.
|
||||||
|
|
||||||
|
## Serve the overlay assets
|
||||||
|
|
||||||
|
Copy `room-overlay.html` and `room-overlay.js` into the signage server's web root (the
|
||||||
|
same directory that serves the SPA), so they're reachable at
|
||||||
|
`https://<your-server>/room-overlay.html`. They must be **same-origin** with the player
|
||||||
|
(the overlay runs in an iframe under the server's CSP).
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
# edit config.json: api_base, api_token (st_ token with the 'full' scope),
|
||||||
|
# overlay_base_url, device_id (a device OR a group id), room_name, ics_url
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # or: node room.js config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop with Ctrl-C — it clears the overlay on the way out.
|
||||||
|
|
||||||
|
### Local quick-start (self-signed dev server)
|
||||||
|
|
||||||
|
For a local ScreenTinker instance on `https://localhost:3443` with a self-signed cert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"room_name": "Aspen Room",
|
||||||
|
"api_base": "https://localhost:3443/",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://localhost:3443/room-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
"ics_url": "https://calendar.google.com/calendar/ical/.../basic.ics",
|
||||||
|
"poll_interval_sec": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED=0 node room.js config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(`NODE_TLS_REJECT_UNAUTHORIZED=0` only to accept the dev cert — never in production.)
|
||||||
|
Remember to copy `room-overlay.html` + `room-overlay.js` into the server's web root first.
|
||||||
|
|
||||||
|
## Offline demo / test
|
||||||
|
|
||||||
|
`test.js` runs the ICS parser and status logic against `fixture-room.ics` at a fixed
|
||||||
|
clock — no server, no network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also drive the overlay against the fixture by setting `ics_file` (instead of
|
||||||
|
`ics_url`) in `config.json`.
|
||||||
|
|
||||||
|
## Config reference
|
||||||
|
|
||||||
|
| key | meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `room_name` | label shown on the overlay |
|
||||||
|
| `api_base` | ScreenTinker server base URL |
|
||||||
|
| `api_token` | `st_` API token with the **full** scope |
|
||||||
|
| `overlay_base_url` | URL where `room-overlay.html` is served (same-origin with the player) |
|
||||||
|
| `device_id` | target device **or** group id |
|
||||||
|
| `ics_url` | calendar feed URL (or use `ics_file` for a local file) |
|
||||||
|
| `poll_interval_sec` | refresh cadence (default 120) |
|
||||||
|
| `colors.available` / `colors.busy` | band colors, 6-hex no `#` |
|
||||||
|
| `overlay.position` | `center` (default), `top-right`, `top-left`, `bottom-right`, `bottom-left` |
|
||||||
|
| `overlay.width` / `overlay.height` / `overlay.border_radius` | overlay box geometry |
|
||||||
|
|
||||||
|
## Time-zone note
|
||||||
|
|
||||||
|
DTSTART/DTEND in UTC (`…Z`) are handled exactly. A *floating* time (no `Z`) is read as
|
||||||
|
the **local time of the machine running `room.js`**, and `TZID` parameters are not
|
||||||
|
resolved to their zone. For a single room whose host shares the room's timezone this is
|
||||||
|
correct; for cross-timezone calendars, publish the feed in UTC.
|
||||||
17
Examples/PIP-Room-Status-Calendar/config.example.json
Normal file
17
Examples/PIP-Room-Status-Calendar/config.example.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"room_name": "Aspen Room",
|
||||||
|
"poll_interval_sec": 120,
|
||||||
|
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/room-overlay.html",
|
||||||
|
"device_id": "DEVICE_OR_GROUP_ID",
|
||||||
|
|
||||||
|
"ics_url": "https://calendar.google.com/calendar/ical/your-room%40group.calendar.google.com/private-xxxxxxxx/basic.ics",
|
||||||
|
|
||||||
|
"colors": { "available": "1f9d55", "busy": "CC0000" },
|
||||||
|
"overlay": { "position": "center", "width": 900, "height": 360, "border_radius": 16 },
|
||||||
|
|
||||||
|
"_offline_demo": "to test against the bundled fixture instead of a live calendar, drop ics_url and add:",
|
||||||
|
"ics_file": null
|
||||||
|
}
|
||||||
30
Examples/PIP-Room-Status-Calendar/fixture-room.ics
Normal file
30
Examples/PIP-Room-Status-Calendar/fixture-room.ics
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//ScreenTinker//Room Status Example//EN
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:standup-0618@example.com
|
||||||
|
DTSTART:20260618T093000Z
|
||||||
|
DTEND:20260618T094500Z
|
||||||
|
SUMMARY:Daily Standup
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:sprint-0618@example.com
|
||||||
|
DTSTART:20260618T140000Z
|
||||||
|
DTEND:20260618T150000Z
|
||||||
|
SUMMARY:Sprint Planning
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:oneonone-0618@example.com
|
||||||
|
DTSTART:20260618T160000Z
|
||||||
|
DTEND:20260618T163000Z
|
||||||
|
SUMMARY:1:1 with Da
|
||||||
|
na
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:retro-0618@example.com
|
||||||
|
DTSTART:20260618T170000Z
|
||||||
|
DTEND:20260618T180000Z
|
||||||
|
SUMMARY:Quarterly Retro\, room A
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
12
Examples/PIP-Room-Status-Calendar/package.json
Normal file
12
Examples/PIP-Room-Status-Calendar/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "pip-room-status-calendar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: turn a ScreenTinker display into a meeting-room Available/Busy sign driven by an ICS calendar feed, via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "room.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node room.js",
|
||||||
|
"test": "node test.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" }
|
||||||
|
}
|
||||||
33
Examples/PIP-Room-Status-Calendar/room-overlay.html
Normal file
33
Examples/PIP-Room-Status-Calendar/room-overlay.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Room Status</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||||
|
.band { padding: 18px 26px; display: flex; align-items: center; gap: 16px; font-weight: 800;
|
||||||
|
letter-spacing: .05em; text-transform: uppercase; font-size: clamp(28px, 7vw, 64px); }
|
||||||
|
.band .dot { width: 18px; height: 18px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||||
|
box-shadow: 0 0 14px rgba(255,255,255,.7); }
|
||||||
|
.body { padding: 20px 26px; display: flex; flex-direction: column; gap: 12px; flex: 1; }
|
||||||
|
.room { font-size: clamp(18px, 3.4vw, 28px); font-weight: 600; color: #e8e8e8; }
|
||||||
|
.detail { font-size: clamp(20px, 4.4vw, 34px); font-weight: 700; line-height: 1.15; }
|
||||||
|
.sub { margin-top: auto; font-size: clamp(15px, 3vw, 22px); color: #b9b9b9; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="band" id="band"><span class="dot"></span><span id="state">—</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="room" id="room"></div>
|
||||||
|
<div class="detail" id="detail"></div>
|
||||||
|
<div class="sub" id="sub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="room-overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
Examples/PIP-Room-Status-Calendar/room-overlay.js
Normal file
13
Examples/PIP-Room-Status-Calendar/room-overlay.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the room status from the URL query string and paints the card.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||||
|
|
||||||
|
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || '1f9d55');
|
||||||
|
document.getElementById('band').style.background = color;
|
||||||
|
document.getElementById('state').textContent = (get('state') || '—').toUpperCase();
|
||||||
|
document.getElementById('room').textContent = get('room') || '';
|
||||||
|
document.getElementById('detail').textContent = get('detail') || '';
|
||||||
|
document.getElementById('sub').textContent = get('sub') || '';
|
||||||
|
})();
|
||||||
255
Examples/PIP-Room-Status-Calendar/room.js
Normal file
255
Examples/PIP-Room-Status-Calendar/room.js
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
'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();
|
||||||
45
Examples/PIP-Room-Status-Calendar/test.js
Normal file
45
Examples/PIP-Room-Status-Calendar/test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Offline test for the ICS parser + room status logic. No network, fixed clock.
|
||||||
|
const fs = require('fs');
|
||||||
|
const { parseIcs, status } = require('./room');
|
||||||
|
|
||||||
|
const events = parseIcs(fs.readFileSync('./fixture-room.ics', 'utf8'));
|
||||||
|
|
||||||
|
console.log(`Parsed ${events.length} event(s):\n`);
|
||||||
|
for (const e of events) {
|
||||||
|
console.log(`• ${e.summary} ${new Date(e.start).toISOString()} -> ${new Date(e.end).toISOString()}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 14:30Z is inside Sprint Planning (14:00-15:00); next is the 1:1 at 16:00.
|
||||||
|
const nowBusy = Date.UTC(2026, 5, 18, 14, 30, 0);
|
||||||
|
const sBusy = status(events, nowBusy);
|
||||||
|
|
||||||
|
// 15:30Z is between meetings; room free, next is the 1:1 at 16:00.
|
||||||
|
const nowFree = Date.UTC(2026, 5, 18, 15, 30, 0);
|
||||||
|
const sFree = status(events, nowFree);
|
||||||
|
|
||||||
|
const fold = events.find(e => e.summary === '1:1 with Dana'); // proves line-unfolding
|
||||||
|
const esc = events.find(e => e.summary === 'Quarterly Retro, room A'); // proves TEXT unescaping
|
||||||
|
|
||||||
|
const ok =
|
||||||
|
events.length === 4 &&
|
||||||
|
sBusy.state === 'busy' &&
|
||||||
|
sBusy.current && sBusy.current.summary === 'Sprint Planning' &&
|
||||||
|
sBusy.busyUntil === Date.UTC(2026, 5, 18, 15, 0, 0) &&
|
||||||
|
sBusy.next && sBusy.next.summary === '1:1 with Dana' &&
|
||||||
|
sFree.state === 'available' &&
|
||||||
|
sFree.current === null &&
|
||||||
|
sFree.next && sFree.next.summary === '1:1 with Dana' &&
|
||||||
|
sFree.freeUntil === Date.UTC(2026, 5, 18, 16, 0, 0) &&
|
||||||
|
!!fold && !!esc;
|
||||||
|
|
||||||
|
console.log('--- assertions ---');
|
||||||
|
console.log('at 14:30Z =>', sBusy.state, '|', sBusy.current && sBusy.current.summary, '| next:', sBusy.next && sBusy.next.summary);
|
||||||
|
console.log('at 15:30Z =>', sFree.state, '| next:', sFree.next && sFree.next.summary);
|
||||||
|
console.log('folded summary parsed:', !!fold, '("1:1 with Dana")');
|
||||||
|
console.log('escaped summary parsed:', !!esc, '("Quarterly Retro, room A")');
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
4
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/.gitignore
vendored
Normal file
4
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
config.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
demo-noaa.json
|
||||||
36
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/alert-overlay.html
Normal file
36
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/alert-overlay.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Emergency Alert</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: transparent; }
|
||||||
|
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||||
|
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||||
|
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||||
|
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
|
||||||
|
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(18px, 4vw, 30px); }
|
||||||
|
.band .pulse { width: 16px; height: 16px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||||
|
animation: pulse 1.1s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||||
|
.body { padding: 18px 24px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
|
||||||
|
.headline { font-size: clamp(20px, 5vw, 38px); font-weight: 700; line-height: 1.15; }
|
||||||
|
.meta { font-size: clamp(13px, 2.6vw, 18px); color: #cfcfcf; display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
||||||
|
.meta b { color: #fff; font-weight: 600; }
|
||||||
|
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||||
|
.agency { opacity: .8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="band" id="band"><span class="pulse"></span><span id="level">ALERT</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<div class="headline" id="headline"></div>
|
||||||
|
<div class="meta" id="meta"></div>
|
||||||
|
<div class="footer"><span class="agency" id="agency"></span> <span id="updated"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="overlay.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
183
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/cap-parse.js
Normal file
183
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/cap-parse.js
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
|
||||||
|
// wrap their alerts the same way). Three jobs:
|
||||||
|
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
|
||||||
|
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
|
||||||
|
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
|
||||||
|
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
|
||||||
|
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
|
||||||
|
// flip in one place so callers never have to think about it.
|
||||||
|
|
||||||
|
const { XMLParser } = require('fast-xml-parser');
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
|
||||||
|
parseTagValue: false, // keep everything as strings; we coerce deliberately
|
||||||
|
trimValues: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always work with arrays even when the XML has a single child.
|
||||||
|
function arr(x) {
|
||||||
|
if (x === undefined || x === null) return [];
|
||||||
|
return Array.isArray(x) ? x : [x];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
|
||||||
|
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
|
||||||
|
// these instead of regexing the HTML-encoded <description> blob.
|
||||||
|
function paramsToMap(info) {
|
||||||
|
const out = {};
|
||||||
|
for (const p of arr(info && info.parameter)) {
|
||||||
|
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
|
||||||
|
function parsePolygon(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const pts = String(str).trim().split(/\s+/).map((pair) => {
|
||||||
|
const [lat, lon] = pair.split(',').map(Number);
|
||||||
|
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
return pts.length >= 3 ? pts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
|
||||||
|
// which can never contain anything, so callers should treat a 0-radius circle as "no
|
||||||
|
// usable circle" and rely on the polygon.
|
||||||
|
function parseCircle(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
const [center, radius] = String(str).trim().split(/\s+/);
|
||||||
|
const [lat, lon] = (center || '').split(',').map(Number);
|
||||||
|
const km = Number(radius);
|
||||||
|
if (![lat, lon, km].every(Number.isFinite)) return null;
|
||||||
|
return { lat, lon, km };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
|
||||||
|
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
|
||||||
|
function pointInPolygon(pt, poly) {
|
||||||
|
const x = pt.lon, y = pt.lat;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||||
|
const xi = poly[i].lon, yi = poly[i].lat;
|
||||||
|
const xj = poly[j].lon, yj = poly[j].lat;
|
||||||
|
const intersect = (yi > y) !== (yj > y) &&
|
||||||
|
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineKm(a, b) {
|
||||||
|
const R = 6371;
|
||||||
|
const toRad = (d) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(b.lat - a.lat);
|
||||||
|
const dLon = toRad(b.lon - a.lon);
|
||||||
|
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
|
||||||
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
|
||||||
|
// circle. Returns false when the alert has no usable geometry.
|
||||||
|
function pointInAlertArea(point, alert) {
|
||||||
|
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
|
||||||
|
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten one embedded CAP <alert> into the shape the monitor works with.
|
||||||
|
function normaliseAlert(a) {
|
||||||
|
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
|
||||||
|
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
|
||||||
|
const params = paramsToMap(info);
|
||||||
|
return {
|
||||||
|
identifier: a.identifier != null ? String(a.identifier) : null,
|
||||||
|
msgType: a.msgType || null, // Alert | Update | Cancel
|
||||||
|
sent: a.sent || null,
|
||||||
|
headline: info.headline || params.IncidentName || '(no headline)',
|
||||||
|
event: info.event || null,
|
||||||
|
category: info.category || null,
|
||||||
|
responseType: info.responseType || null, // mostly "Monitor" in this feed
|
||||||
|
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
|
||||||
|
expires: info.expires || null,
|
||||||
|
web: info.web || null,
|
||||||
|
// RFS-specific, the field that actually carries urgency:
|
||||||
|
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
|
||||||
|
incidentType: params.IncidentType || null,
|
||||||
|
status: params.Status || null,
|
||||||
|
size: params.Fireground || params.Size || null,
|
||||||
|
council: params.CouncilArea || params.Location || null,
|
||||||
|
isFire: (params.IsFire || '').toLowerCase() === 'yes',
|
||||||
|
polygon: parsePolygon(area.polygon),
|
||||||
|
circle: parseCircle(area.circle),
|
||||||
|
areaDesc: area.areaDesc || null,
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
|
||||||
|
function parseFeed(xml) {
|
||||||
|
const root = parser.parse(xml);
|
||||||
|
const dist = root.EDXLDistribution || root.Distribution || null;
|
||||||
|
const alerts = [];
|
||||||
|
if (dist) {
|
||||||
|
for (const co of arr(dist.contentObject)) {
|
||||||
|
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
|
||||||
|
for (const e of arr(embedded)) {
|
||||||
|
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: a bare CAP feed (no EDXL envelope).
|
||||||
|
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
|
||||||
|
}
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
|
||||||
|
function isExpired(alert, now = Date.now()) {
|
||||||
|
if (!alert.expires) return false;
|
||||||
|
const t = Date.parse(alert.expires);
|
||||||
|
return Number.isFinite(t) && t <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate: should this alert put something on a screen at `point`?
|
||||||
|
// - msgType must be Alert/Update (Cancel clears, never shows)
|
||||||
|
// - not expired
|
||||||
|
// - AlertLevel is at or above the configured threshold
|
||||||
|
// - the screen falls inside the alert area
|
||||||
|
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
|
||||||
|
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
|
||||||
|
|
||||||
|
function shouldShow(alert, point, opts = {}) {
|
||||||
|
const levels = opts.alertLevels || DEFAULT_LEVELS;
|
||||||
|
const now = opts.now || Date.now();
|
||||||
|
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
|
||||||
|
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
|
||||||
|
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
|
||||||
|
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
|
||||||
|
}
|
||||||
|
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
|
||||||
|
return { show: false, reason: 'no usable geometry' };
|
||||||
|
}
|
||||||
|
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
|
||||||
|
return { show: true, reason: 'in-area, at/above threshold' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parseFeed,
|
||||||
|
normaliseAlert,
|
||||||
|
parsePolygon,
|
||||||
|
parseCircle,
|
||||||
|
pointInPolygon,
|
||||||
|
pointInAlertArea,
|
||||||
|
haversineKm,
|
||||||
|
isExpired,
|
||||||
|
shouldShow,
|
||||||
|
DEFAULT_LEVELS,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"source": "noaa",
|
||||||
|
"poll_interval_sec": 60,
|
||||||
|
|
||||||
|
"api_base": "https://signage.example.com",
|
||||||
|
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||||
|
"overlay_base_url": "https://signage.example.com/alert-overlay.html",
|
||||||
|
|
||||||
|
"min_severity": "Severe",
|
||||||
|
"urgencies": null,
|
||||||
|
"noaa_user_agent": "ScreenTinker-CAP-Alert-Monitor (you@example.com)",
|
||||||
|
|
||||||
|
"screens": [
|
||||||
|
{ "name": "OKC lobby", "lat": 35.4676, "lon": -97.5164, "device_id": "DEVICE_OR_GROUP_ID" }
|
||||||
|
],
|
||||||
|
|
||||||
|
"overlay": { "position": "center", "width": 900, "height": 320, "border_radius": 16 },
|
||||||
|
|
||||||
|
"_demo": "to watch show->expire-removal deterministically: run `node make-demo-alert.js 90`, then add the next line:",
|
||||||
|
"test_feed_file": null
|
||||||
|
}
|
||||||
82
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-feed.xml
Normal file
82
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-feed.xml
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" standalone="no"?><?xml-stylesheet href="lib/RFS_EDXL_simple.xsl" type="text/xsl"?>
|
||||||
|
<EDXLDistribution xmlns="urn:oasis:names:tc:emergency:EDXL:DE:1.0">
|
||||||
|
<distributionID>RFSUniqueID:2026-06-18T00:00:00Z</distributionID>
|
||||||
|
<senderID>webmaster@rfs.nsw.gov.au</senderID>
|
||||||
|
<dateTimeSent>2026-06-18T10:00:00+10:00</dateTimeSent>
|
||||||
|
<distributionStatus>Actual</distributionStatus>
|
||||||
|
<distributionType>Report</distributionType>
|
||||||
|
<contentObject>
|
||||||
|
<contentDescription>Information on Aberdeen HR</contentDescription>
|
||||||
|
<xmlContent><embeddedXMLContent>
|
||||||
|
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||||
|
<identifier>2026-06-17T14:46:00.0000000:662900</identifier>
|
||||||
|
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-17T14:46:00+10:00</sent>
|
||||||
|
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||||
|
<info>
|
||||||
|
<category>Fire</category><event>Fire</event><responseType>Monitor</responseType>
|
||||||
|
<urgency>Unknown</urgency><severity>Unknown</severity><certainty>Observed</certainty>
|
||||||
|
<expires>2026-06-30T21:25:21+10:00</expires>
|
||||||
|
<headline>Aberdeen HR</headline>
|
||||||
|
<parameter><valueName>AlertLevel</valueName><value>Planned Burn</value></parameter>
|
||||||
|
<parameter><valueName>IncidentType</valueName><value>Hazard Reduction</value></parameter>
|
||||||
|
<parameter><valueName>Status</valueName><value>Under control</value></parameter>
|
||||||
|
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||||
|
<area>
|
||||||
|
<areaDesc>STANBOROUGH</areaDesc>
|
||||||
|
<polygon>-29.974,151.103 -29.984,151.103 -29.984,151.108 -29.974,151.108 -29.974,151.103</polygon>
|
||||||
|
<circle>-29.978,151.105 0</circle>
|
||||||
|
</area>
|
||||||
|
</info>
|
||||||
|
</alert>
|
||||||
|
</embeddedXMLContent></xmlContent>
|
||||||
|
</contentObject>
|
||||||
|
<contentObject>
|
||||||
|
<contentDescription>Emergency Warning - Test Ridge</contentDescription>
|
||||||
|
<xmlContent><embeddedXMLContent>
|
||||||
|
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||||
|
<identifier>2026-06-18T09:30:00.0000000:670001</identifier>
|
||||||
|
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:30:00+10:00</sent>
|
||||||
|
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||||
|
<info>
|
||||||
|
<category>Fire</category><event>Bushfire</event><responseType>Evacuate</responseType>
|
||||||
|
<urgency>Immediate</urgency><severity>Extreme</severity><certainty>Observed</certainty>
|
||||||
|
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||||
|
<headline>Test Ridge Road Fire</headline>
|
||||||
|
<parameter><valueName>AlertLevel</valueName><value>Emergency Warning</value></parameter>
|
||||||
|
<parameter><valueName>IncidentType</valueName><value>Bush Fire</value></parameter>
|
||||||
|
<parameter><valueName>Status</valueName><value>Out of control</value></parameter>
|
||||||
|
<parameter><valueName>CouncilArea</valueName><value>Testshire</value></parameter>
|
||||||
|
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||||
|
<area>
|
||||||
|
<areaDesc>Test Ridge - 5km around the screen</areaDesc>
|
||||||
|
<polygon>-33.90,151.10 -33.90,151.30 -33.80,151.30 -33.80,151.10 -33.90,151.10</polygon>
|
||||||
|
<circle>-33.85,151.20 8</circle>
|
||||||
|
</area>
|
||||||
|
</info>
|
||||||
|
</alert>
|
||||||
|
</embeddedXMLContent></xmlContent>
|
||||||
|
</contentObject>
|
||||||
|
<contentObject>
|
||||||
|
<contentDescription>Watch and Act - far away</contentDescription>
|
||||||
|
<xmlContent><embeddedXMLContent>
|
||||||
|
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||||
|
<identifier>2026-06-18T09:45:00.0000000:670002</identifier>
|
||||||
|
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:45:00+10:00</sent>
|
||||||
|
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||||
|
<info>
|
||||||
|
<category>Fire</category><event>Bushfire</event><responseType>Prepare</responseType>
|
||||||
|
<urgency>Expected</urgency><severity>Severe</severity><certainty>Likely</certainty>
|
||||||
|
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||||
|
<headline>Distant Valley Fire</headline>
|
||||||
|
<parameter><valueName>AlertLevel</valueName><value>Watch and Act</value></parameter>
|
||||||
|
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||||
|
<area>
|
||||||
|
<areaDesc>Distant Valley (far from screen)</areaDesc>
|
||||||
|
<polygon>-31.00,150.00 -31.00,150.10 -30.90,150.10 -30.90,150.00 -31.00,150.00</polygon>
|
||||||
|
<circle>-30.95,150.05 0</circle>
|
||||||
|
</area>
|
||||||
|
</info>
|
||||||
|
</alert>
|
||||||
|
</embeddedXMLContent></xmlContent>
|
||||||
|
</contentObject>
|
||||||
|
</EDXLDistribution>
|
||||||
90
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-noaa.json
Normal file
90
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-noaa.json
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
{
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"title": "Fixture: NWS active alerts (offline test for noaa-parse)",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"id": "NWS-TEST-TORNADO-1",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"id": "NWS-TEST-TORNADO-1",
|
||||||
|
"event": "Tornado Warning",
|
||||||
|
"severity": "Extreme",
|
||||||
|
"urgency": "Immediate",
|
||||||
|
"certainty": "Observed",
|
||||||
|
"messageType": "Alert",
|
||||||
|
"status": "Actual",
|
||||||
|
"sent": "2026-06-18T10:00:00-05:00",
|
||||||
|
"effective": "2026-06-18T10:00:00-05:00",
|
||||||
|
"expires": "2026-06-18T10:01:00-05:00",
|
||||||
|
"headline": "Tornado Warning issued June 18 at 10:00AM CDT",
|
||||||
|
"areaDesc": "Test County, ST",
|
||||||
|
"senderName": "NWS Test Office",
|
||||||
|
"response": "Shelter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NWS-TEST-WINTER-3",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"id": "NWS-TEST-WINTER-3",
|
||||||
|
"event": "Winter Storm Warning",
|
||||||
|
"severity": "Severe",
|
||||||
|
"urgency": "Expected",
|
||||||
|
"certainty": "Likely",
|
||||||
|
"messageType": "Alert",
|
||||||
|
"status": "Actual",
|
||||||
|
"sent": "2026-06-18T09:30:00-05:00",
|
||||||
|
"effective": "2026-06-18T09:30:00-05:00",
|
||||||
|
"expires": "2026-06-18T20:00:00-05:00",
|
||||||
|
"headline": "Winter Storm Warning in effect",
|
||||||
|
"areaDesc": "Test County, ST",
|
||||||
|
"senderName": "NWS Test Office",
|
||||||
|
"response": "Prepare"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NWS-TEST-FLOOD-2",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"id": "NWS-TEST-FLOOD-2",
|
||||||
|
"event": "Flood Advisory",
|
||||||
|
"severity": "Minor",
|
||||||
|
"urgency": "Expected",
|
||||||
|
"certainty": "Likely",
|
||||||
|
"messageType": "Alert",
|
||||||
|
"status": "Actual",
|
||||||
|
"sent": "2026-06-18T09:45:00-05:00",
|
||||||
|
"effective": "2026-06-18T09:45:00-05:00",
|
||||||
|
"expires": "2026-06-18T20:00:00-05:00",
|
||||||
|
"headline": "Flood Advisory in effect",
|
||||||
|
"areaDesc": "Test County, ST",
|
||||||
|
"senderName": "NWS Test Office",
|
||||||
|
"response": "Avoid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "NWS-TEST-CANCEL-4",
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": null,
|
||||||
|
"properties": {
|
||||||
|
"id": "NWS-TEST-CANCEL-4",
|
||||||
|
"event": "Severe Thunderstorm Warning",
|
||||||
|
"severity": "Severe",
|
||||||
|
"urgency": "Immediate",
|
||||||
|
"certainty": "Observed",
|
||||||
|
"messageType": "Cancel",
|
||||||
|
"status": "Actual",
|
||||||
|
"sent": "2026-06-18T09:55:00-05:00",
|
||||||
|
"effective": "2026-06-18T09:55:00-05:00",
|
||||||
|
"expires": "2026-06-18T20:00:00-05:00",
|
||||||
|
"headline": "Severe Thunderstorm Warning cancelled",
|
||||||
|
"areaDesc": "Test County, ST",
|
||||||
|
"senderName": "NWS Test Office",
|
||||||
|
"response": "AllClear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/make-demo-alert.js
Normal file
25
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/make-demo-alert.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Usage: node make-demo-alert.js [seconds] [outfile]
|
||||||
|
// Writes a NWS-shaped FeatureCollection with one Extreme alert expiring `seconds` from now
|
||||||
|
// (default 90). Point the monitor's config.test_feed_file at the output to watch show->expire.
|
||||||
|
const fs = require('fs');
|
||||||
|
const secs = parseInt(process.argv[2] || '90', 10);
|
||||||
|
const out = process.argv[3] || 'demo-noaa.json';
|
||||||
|
const now = new Date();
|
||||||
|
const expires = new Date(now.getTime() + secs * 1000);
|
||||||
|
const fc = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
id: 'https://api.weather.gov/alerts/DEMO-EXPIRY-1', type: 'Feature', geometry: null,
|
||||||
|
properties: {
|
||||||
|
id: 'DEMO-EXPIRY-1', areaDesc: 'Demo County',
|
||||||
|
sent: now.toISOString(), effective: now.toISOString(), onset: now.toISOString(),
|
||||||
|
expires: expires.toISOString(), ends: expires.toISOString(),
|
||||||
|
status: 'Actual', messageType: 'Alert', category: 'Met',
|
||||||
|
severity: 'Extreme', certainty: 'Observed', urgency: 'Immediate',
|
||||||
|
event: 'Tornado Warning', senderName: 'NWS Demo Office',
|
||||||
|
headline: `DEMO alert — auto-clears at ${expires.toLocaleTimeString()}`, response: 'Shelter',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
fs.writeFileSync(out, JSON.stringify(fc, null, 2));
|
||||||
|
console.log(`wrote ${out}: DEMO Tornado Warning expiring in ${secs}s (at ${expires.toISOString()})`);
|
||||||
247
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/monitor.js
Normal file
247
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/monitor.js
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// CAP -> ScreenTinker PiP monitor. Supports two sources via config.source:
|
||||||
|
// "capau" (default) - NSW RFS EDXL/CAP-AU feed, client-side polygon geofence, gate on AlertLevel.
|
||||||
|
// "noaa" - api.weather.gov, server-side ?point= geofence, gate on real CAP severity.
|
||||||
|
//
|
||||||
|
// For each configured screen it pushes a PiP web overlay when a qualifying alert covers
|
||||||
|
// that screen, and clears it when the alert expires, is cancelled, or drops out. Overlays
|
||||||
|
// also self-remove at the alert's `expires` time via the PiP `duration` field (the player
|
||||||
|
// auto-clears), so they vanish on expiry even between polls.
|
||||||
|
//
|
||||||
|
// node monitor.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');
|
||||||
|
const cap = require('./cap-parse');
|
||||||
|
const noaa = require('./noaa-parse');
|
||||||
|
|
||||||
|
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 SOURCE = (cfg.source || 'capau').toLowerCase();
|
||||||
|
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 SCREENS = cfg.screens || [];
|
||||||
|
const OVERLAY = cfg.overlay || {};
|
||||||
|
const PIP_DUR_MAX = 86400; // PiP API cap (seconds)
|
||||||
|
|
||||||
|
// capau-only:
|
||||||
|
const FEED_URL = cfg.feed_url || 'https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml';
|
||||||
|
const ALERT_LEVELS = cfg.alert_levels || cap.DEFAULT_LEVELS;
|
||||||
|
const CAPAU_COLORS = Object.assign({ 'Emergency Warning': 'CC0000', 'Watch and Act': 'E8730C', 'Advice': 'F2C200' }, OVERLAY.colors || {});
|
||||||
|
|
||||||
|
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || SCREENS.length === 0) {
|
||||||
|
console.error('config must set api_base, api_token, overlay_base_url, and at least one screen.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// active overlays: key `${device_id}|${identifier}` -> { pip_id, expiresAt }
|
||||||
|
const active = new Map();
|
||||||
|
const keyFor = (deviceId, identifier) => `${deviceId}|${identifier}`;
|
||||||
|
|
||||||
|
// Map a normalised alert (either source) to the overlay's display fields.
|
||||||
|
function viewOf(alert) {
|
||||||
|
if (alert.source === 'noaa') {
|
||||||
|
return {
|
||||||
|
level: alert.displayLevel, color: alert.color, headline: alert.headline,
|
||||||
|
area: alert.areaDesc || '', status: alert.response || alert.urgency || '',
|
||||||
|
updated: alert.sent || '', agency: alert.agency || 'US National Weather Service',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: alert.alertLevel || 'Alert',
|
||||||
|
color: CAPAU_COLORS[alert.alertLevel] || 'CC0000',
|
||||||
|
headline: alert.headline || '',
|
||||||
|
area: alert.areaDesc || alert.council || '',
|
||||||
|
status: alert.status || '',
|
||||||
|
updated: alert.sent || '',
|
||||||
|
agency: OVERLAY.agency || 'NSW Rural Fire Service',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayUri(alert) {
|
||||||
|
const v = viewOf(alert);
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
level: v.level || '', headline: v.headline || '', area: v.area || '',
|
||||||
|
status: v.status || '', updated: v.updated || '',
|
||||||
|
color: (v.color || 'CC0000').replace(/[^0-9a-fA-F]/g, ''), agency: v.agency || '',
|
||||||
|
});
|
||||||
|
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seconds until expiry, clamped to the PiP duration range. 0 => keep until we clear it.
|
||||||
|
function durationForExpiry(alert, now = Date.now()) {
|
||||||
|
if (!alert.expires) return 0;
|
||||||
|
const t = Date.parse(alert.expires);
|
||||||
|
if (!Number.isFinite(t)) return 0;
|
||||||
|
const secs = Math.floor((t - now) / 1000);
|
||||||
|
if (secs <= 0) return 0;
|
||||||
|
return Math.min(secs, PIP_DUR_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipShow(deviceId, alert) {
|
||||||
|
const body = {
|
||||||
|
device_id: deviceId, type: 'web', uri: overlayUri(alert),
|
||||||
|
position: OVERLAY.position || 'center',
|
||||||
|
width: OVERLAY.width || 900, height: OVERLAY.height || 320,
|
||||||
|
duration: durationForExpiry(alert),
|
||||||
|
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||||
|
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||||
|
close_button: false,
|
||||||
|
title: viewOf(alert).level,
|
||||||
|
};
|
||||||
|
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 { pipId: json.pip_id, duration: body.duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipClear(deviceId, 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: deviceId, pip_id: pipId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate without geofence (for the test_feed_file override, where geometry/point isn't real).
|
||||||
|
function passesNonGeo(alert, now) {
|
||||||
|
if (alert.msgType === 'Cancel') return false;
|
||||||
|
if (SOURCE === 'noaa') {
|
||||||
|
if (alert.status && alert.status !== 'Actual') return false;
|
||||||
|
if (noaa.isExpired(alert, now)) return false;
|
||||||
|
return (noaa.SEV_RANK[alert.severity] || 0) >= (noaa.SEV_RANK[cfg.min_severity || 'Severe'] || 0);
|
||||||
|
}
|
||||||
|
if (cap.isExpired(alert, now)) return false;
|
||||||
|
return !!alert.alertLevel && ALERT_LEVELS.includes(alert.alertLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect(now) {
|
||||||
|
const pairs = [];
|
||||||
|
const polled = new Set();
|
||||||
|
|
||||||
|
// Test/demo override: read alerts from a local file instead of the network, geofence
|
||||||
|
// bypassed (every alert applies to every screen). Lets you watch the show->expire->remove
|
||||||
|
// lifecycle on a deterministic timer. Remove `test_feed_file` from config for real use.
|
||||||
|
if (cfg.test_feed_file) {
|
||||||
|
let alerts = [];
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(cfg.test_feed_file, 'utf8');
|
||||||
|
alerts = SOURCE === 'noaa' ? noaa.normaliseFeatureCollection(raw) : cap.parseFeed(raw);
|
||||||
|
} catch (e) { console.error(`test_feed_file read error: ${e.message}`); return { pairs, polled }; }
|
||||||
|
for (const screen of SCREENS) {
|
||||||
|
polled.add(screen.device_id);
|
||||||
|
for (const a of alerts) {
|
||||||
|
if (a.identifier && passesNonGeo(a, now)) pairs.push({ screen, alert: a });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { pairs, polled };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SOURCE === 'noaa') {
|
||||||
|
for (const screen of SCREENS) {
|
||||||
|
let alerts;
|
||||||
|
try { alerts = await noaa.fetchActiveForPoint(screen.lat, screen.lon, cfg.noaa_user_agent); }
|
||||||
|
catch (e) { console.error(`[${new Date().toISOString()}] NWS fetch error for ${screen.name}: ${e.message}`); continue; }
|
||||||
|
polled.add(screen.device_id);
|
||||||
|
for (const a of alerts) {
|
||||||
|
if (!a.identifier) continue;
|
||||||
|
if (noaa.shouldShow(a, { minSeverity: cfg.min_severity, urgencies: cfg.urgencies, now }).show) {
|
||||||
|
pairs.push({ screen, alert: a });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let alerts;
|
||||||
|
try {
|
||||||
|
const res = await fetch(FEED_URL, { headers: { Accept: 'application/xml, text/xml' } });
|
||||||
|
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
|
||||||
|
alerts = cap.parseFeed(await res.text());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] feed fetch/parse error: ${e.message}`);
|
||||||
|
return { pairs: [], polled };
|
||||||
|
}
|
||||||
|
for (const screen of SCREENS) {
|
||||||
|
polled.add(screen.device_id);
|
||||||
|
const point = { lat: screen.lat, lon: screen.lon };
|
||||||
|
for (const a of alerts) {
|
||||||
|
if (!a.identifier) continue;
|
||||||
|
if (cap.shouldShow(a, { alertLevels: ALERT_LEVELS, now }).show) pairs.push({ screen, alert: a });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { pairs, polled };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
const { pairs, polled } = await collect(now);
|
||||||
|
const stillQualifying = new Set();
|
||||||
|
|
||||||
|
for (const { screen, alert } of pairs) {
|
||||||
|
const key = keyFor(screen.device_id, alert.identifier);
|
||||||
|
stillQualifying.add(key);
|
||||||
|
if (active.has(key)) continue;
|
||||||
|
try {
|
||||||
|
const { pipId, duration } = await pipShow(screen.device_id, alert);
|
||||||
|
active.set(key, { pip_id: pipId, expiresAt: Date.parse(alert.expires) || null });
|
||||||
|
const v = viewOf(alert);
|
||||||
|
console.log(`[${new Date().toISOString()}] SHOW "${alert.headline}" (${v.level}) on ${screen.name} pip=${pipId} dur=${duration || '∞'}s`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] show error on ${screen.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, rec] of [...active.entries()]) {
|
||||||
|
const [deviceId] = key.split('|');
|
||||||
|
if (!polled.has(deviceId)) continue;
|
||||||
|
if (stillQualifying.has(key)) continue;
|
||||||
|
try {
|
||||||
|
await pipClear(deviceId, rec.pip_id);
|
||||||
|
active.delete(key);
|
||||||
|
console.log(`[${new Date().toISOString()}] CLEAR pip=${rec.pip_id} on ${deviceId} (gone/expired/cancelled)`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${new Date().toISOString()}] clear error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`CAP PiP monitor starting — source=${SOURCE}`);
|
||||||
|
console.log(` poll: every ${POLL_SEC}s`);
|
||||||
|
if (SOURCE === 'noaa') console.log(` min severity: ${cfg.min_severity || 'Severe'}${cfg.urgencies ? `, urgency in [${cfg.urgencies.join(',')}]` : ''}`);
|
||||||
|
else console.log(` feed: ${FEED_URL}\n levels: ${ALERT_LEVELS.join(', ')}`);
|
||||||
|
console.log(` screens: ${SCREENS.map(s => `${s.name}(${s.lat},${s.lon})`).join(', ')}`);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
clearInterval(timer);
|
||||||
|
console.log('\nclearing active overlays before exit...');
|
||||||
|
for (const [key, rec] of active.entries()) {
|
||||||
|
const [deviceId] = key.split('|');
|
||||||
|
try { await pipClear(deviceId, rec.pip_id); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
103
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/noaa-parse.js
Normal file
103
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/noaa-parse.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// NOAA / US National Weather Service adapter for api.weather.gov.
|
||||||
|
//
|
||||||
|
// Unlike the RFS CAP-AU feed (EDXL-wrapped XML, geofence client-side, gate on a custom
|
||||||
|
// AlertLevel parameter because CAP severity is "Unknown"), NWS is:
|
||||||
|
// - JSON (GeoJSON FeatureCollection), parsed directly.
|
||||||
|
// - Geofenced BY THE API: /alerts/active?point=lat,lon returns only alerts covering
|
||||||
|
// that point, so there's no polygon math here.
|
||||||
|
// - Gated on the REAL CAP severity/urgency, which NWS actually populates.
|
||||||
|
// - api.weather.gov REQUIRES a User-Agent header (403 without one).
|
||||||
|
//
|
||||||
|
// Exposes a pure normaliser/gate (offline-testable) and a thin live fetch.
|
||||||
|
|
||||||
|
// Severity ranking for threshold comparison.
|
||||||
|
const SEV_RANK = { Extreme: 4, Severe: 3, Moderate: 2, Minor: 1, Unknown: 0 };
|
||||||
|
|
||||||
|
// Default colours by severity (overridable via cfg.colors).
|
||||||
|
const SEV_COLORS = { Extreme: '7B0000', Severe: 'CC0000', Moderate: 'E8730C', Minor: 'F2C200', Unknown: '888888' };
|
||||||
|
|
||||||
|
// Normalise one GeoJSON feature's `properties` into the shared alert shape the monitor
|
||||||
|
// and overlay use (same field names the CAP-AU path produces, so the rest is source-agnostic).
|
||||||
|
function normaliseFeature(feature) {
|
||||||
|
const p = (feature && feature.properties) || {};
|
||||||
|
const severity = p.severity || 'Unknown';
|
||||||
|
return {
|
||||||
|
source: 'noaa',
|
||||||
|
identifier: p.id || (feature && feature.id) || null,
|
||||||
|
msgType: p.messageType || null, // Alert | Update | Cancel
|
||||||
|
status: p.status || null, // Actual | Exercise | Test | ...
|
||||||
|
sent: p.sent || null,
|
||||||
|
expires: p.expires || p.ends || null, // NWS populates expires reliably; ends as fallback
|
||||||
|
headline: p.headline || p.event || '(no headline)',
|
||||||
|
event: p.event || null,
|
||||||
|
severity,
|
||||||
|
urgency: p.urgency || null, // Immediate | Expected | Future | Past | Unknown
|
||||||
|
certainty: p.certainty || null,
|
||||||
|
response: p.response || null, // Shelter | Evacuate | Prepare | Avoid | Monitor | ...
|
||||||
|
areaDesc: p.areaDesc || null,
|
||||||
|
agency: p.senderName || 'US National Weather Service',
|
||||||
|
web: (p.parameters && p.parameters.WMOidentifier) ? null : null, // NWS has no single web link field
|
||||||
|
// for overlay display:
|
||||||
|
displayLevel: p.event || severity, // the event name reads better than the bare severity
|
||||||
|
color: SEV_COLORS[severity] || SEV_COLORS.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseFeatureCollection(json) {
|
||||||
|
const obj = typeof json === 'string' ? JSON.parse(json) : json;
|
||||||
|
const feats = (obj && Array.isArray(obj.features)) ? obj.features : [];
|
||||||
|
return feats.map(normaliseFeature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(alert, now = Date.now()) {
|
||||||
|
if (!alert.expires) return false;
|
||||||
|
const t = Date.parse(alert.expires);
|
||||||
|
return Number.isFinite(t) && t <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gate: NWS-style. Show if it's a live Alert/Update, not expired, status Actual, and
|
||||||
|
// at/above the severity threshold (default Severe+). Optionally also require an urgency in
|
||||||
|
// cfg.urgencies. Geofencing already happened at fetch time (?point=).
|
||||||
|
function shouldShow(alert, opts = {}) {
|
||||||
|
const minSev = opts.minSeverity || 'Severe';
|
||||||
|
const now = opts.now || Date.now();
|
||||||
|
const urgencies = opts.urgencies || null; // e.g. ["Immediate","Expected"] or null = any
|
||||||
|
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
|
||||||
|
if (alert.status && alert.status !== 'Actual') return { show: false, reason: `status ${alert.status}` };
|
||||||
|
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
|
||||||
|
if ((SEV_RANK[alert.severity] || 0) < (SEV_RANK[minSev] || 0)) {
|
||||||
|
return { show: false, reason: `severity ${alert.severity} below ${minSev}` };
|
||||||
|
}
|
||||||
|
if (urgencies && !urgencies.includes(alert.urgency)) {
|
||||||
|
return { show: false, reason: `urgency ${alert.urgency} not in [${urgencies.join(',')}]` };
|
||||||
|
}
|
||||||
|
return { show: true, reason: `${alert.severity}, at/above ${minSev}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live fetch: alerts active at a point. NWS resolves the point to its zones server-side, so
|
||||||
|
// everything returned already covers the screen. Requires a User-Agent.
|
||||||
|
async function fetchActiveForPoint(lat, lon, userAgent) {
|
||||||
|
// API caps coordinate precision at 4 decimals.
|
||||||
|
const p = `${Number(lat).toFixed(4)},${Number(lon).toFixed(4)}`;
|
||||||
|
const url = `https://api.weather.gov/alerts/active?point=${encodeURIComponent(p)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent || 'ScreenTinker-CAP-Alert-Monitor (set contact in config)',
|
||||||
|
Accept: 'application/geo+json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`NWS HTTP ${res.status}`);
|
||||||
|
return normaliseFeatureCollection(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normaliseFeature,
|
||||||
|
normaliseFeatureCollection,
|
||||||
|
shouldShow,
|
||||||
|
isExpired,
|
||||||
|
fetchActiveForPoint,
|
||||||
|
SEV_RANK,
|
||||||
|
SEV_COLORS,
|
||||||
|
};
|
||||||
29
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/overlay.js
Normal file
29
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/overlay.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||||
|
// Reads the alert fields from the URL query string and populates the card.
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search);
|
||||||
|
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||||
|
|
||||||
|
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||||
|
document.getElementById('band').style.background = color;
|
||||||
|
document.getElementById('level').textContent = (get('level') || 'Alert').toUpperCase();
|
||||||
|
document.getElementById('headline').textContent = get('headline') || 'Emergency alert in your area';
|
||||||
|
document.getElementById('agency').textContent = get('agency') || '';
|
||||||
|
|
||||||
|
var meta = [];
|
||||||
|
if (get('area')) meta.push('<b>Area:</b> ' + escapeHtml(get('area')));
|
||||||
|
if (get('status')) meta.push('<b>Status:</b> ' + escapeHtml(get('status')));
|
||||||
|
document.getElementById('meta').innerHTML = meta.join('');
|
||||||
|
|
||||||
|
var updated = get('updated');
|
||||||
|
if (updated) {
|
||||||
|
var d = new Date(updated);
|
||||||
|
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
15
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/package.json
Normal file
15
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "cap-alert-monitor-noaa",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Example: monitor a CAP feed (NSW RFS CAP-AU or US NWS / api.weather.gov) and push emergency alerts to ScreenTinker screens via the PiP API.",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "monitor.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node monitor.js",
|
||||||
|
"test": "node test-parse.js && node test-noaa.js"
|
||||||
|
},
|
||||||
|
"engines": { "node": ">=18" },
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "^4.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/test-noaa.js
Normal file
47
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/test-noaa.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const noaa = require('./noaa-parse');
|
||||||
|
|
||||||
|
const alerts = noaa.normaliseFeatureCollection(fs.readFileSync('./fixture-noaa.json', 'utf8'));
|
||||||
|
const byId = Object.fromEntries(alerts.map(a => [a.identifier, a]));
|
||||||
|
|
||||||
|
// "now" = 30s before the tornado's 1-minute expiry, so it's still active.
|
||||||
|
const now = Date.parse('2026-06-18T10:00:30-05:00');
|
||||||
|
|
||||||
|
console.log(`Parsed ${alerts.length} NWS alert(s):\n`);
|
||||||
|
for (const a of alerts) {
|
||||||
|
const g = noaa.shouldShow(a, { minSeverity: 'Severe', now });
|
||||||
|
console.log(`• ${a.event} severity=${a.severity} urgency=${a.urgency} msgType=${a.msgType}`);
|
||||||
|
console.log(` expires=${a.expires} => ${g.show ? 'SHOW' : 'skip'} (${g.reason})\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration the player would receive for the tornado (seconds until expiry, capped)
|
||||||
|
function durFor(a, t) {
|
||||||
|
if (!a.expires) return 0;
|
||||||
|
const e = Date.parse(a.expires); if (!Number.isFinite(e)) return 0;
|
||||||
|
return Math.max(0, Math.min(Math.floor((e - t) / 1000), 86400));
|
||||||
|
}
|
||||||
|
const tornado = byId['NWS-TEST-TORNADO-1'];
|
||||||
|
const dur = durFor(tornado, now);
|
||||||
|
|
||||||
|
// after the tornado expires, it must stop qualifying (self-removal path)
|
||||||
|
const later = Date.parse('2026-06-18T10:02:00-05:00');
|
||||||
|
const tornadoAfter = noaa.shouldShow(tornado, { minSeverity: 'Severe', now: later });
|
||||||
|
|
||||||
|
const shown = alerts.filter(a => noaa.shouldShow(a, { minSeverity: 'Severe', now }).show).map(a => a.event);
|
||||||
|
const ok =
|
||||||
|
shown.length === 2 &&
|
||||||
|
shown.includes('Tornado Warning') &&
|
||||||
|
shown.includes('Winter Storm Warning') &&
|
||||||
|
noaa.shouldShow(byId['NWS-TEST-FLOOD-2'], { minSeverity: 'Severe', now }).reason.includes('below') &&
|
||||||
|
noaa.shouldShow(byId['NWS-TEST-CANCEL-4'], { minSeverity: 'Severe', now }).reason === 'cancelled' &&
|
||||||
|
dur === 30 &&
|
||||||
|
tornadoAfter.show === false && tornadoAfter.reason === 'expired';
|
||||||
|
|
||||||
|
console.log('--- assertions ---');
|
||||||
|
console.log('shows (Severe+):', shown.join(', '));
|
||||||
|
console.log('Flood Advisory (Minor) filtered:', noaa.shouldShow(byId['NWS-TEST-FLOOD-2'], { minSeverity: 'Severe', now }).reason);
|
||||||
|
console.log('Cancel filtered:', noaa.shouldShow(byId['NWS-TEST-CANCEL-4'], { minSeverity: 'Severe', now }).reason);
|
||||||
|
console.log(`tornado overlay duration the player gets: ${dur}s (auto-clears at expiry)`);
|
||||||
|
console.log('after expiry, tornado stops qualifying:', tornadoAfter.show === false, `(${tornadoAfter.reason})`);
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
43
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/test-parse.js
Normal file
43
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/test-parse.js
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const cap = require('./cap-parse');
|
||||||
|
|
||||||
|
const xml = fs.readFileSync('./fixture-feed.xml', 'utf8');
|
||||||
|
const alerts = cap.parseFeed(xml);
|
||||||
|
|
||||||
|
// A screen physically located inside the Emergency Warning area.
|
||||||
|
const SCREEN = { lat: -33.85, lon: 151.20 };
|
||||||
|
const now = Date.parse('2026-06-18T10:00:00+10:00');
|
||||||
|
|
||||||
|
console.log(`Parsed ${alerts.length} alert(s) from the EDXL envelope:\n`);
|
||||||
|
for (const a of alerts) {
|
||||||
|
const g = cap.shouldShow(a, SCREEN, { now });
|
||||||
|
console.log(`• ${a.headline}`);
|
||||||
|
console.log(` alertLevel=${a.alertLevel} severity(CAP)=${a.severity} msgType=${a.msgType}`);
|
||||||
|
console.log(` geometry: polygon=${a.polygon ? a.polygon.length + 'pts' : 'none'} circle=${a.circle ? a.circle.km + 'km' : 'none'}`);
|
||||||
|
console.log(` => ${g.show ? 'SHOW PiP' : 'skip'} (${g.reason})\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
const byLevel = Object.fromEntries(alerts.map(a => [a.alertLevel, a]));
|
||||||
|
const results = alerts.map(a => ({ h: a.headline, show: cap.shouldShow(a, SCREEN, { now }).show }));
|
||||||
|
const shown = results.filter(r => r.show).map(r => r.h);
|
||||||
|
|
||||||
|
const expectShown = ['Test Ridge Road Fire'];
|
||||||
|
const ok =
|
||||||
|
shown.length === 1 &&
|
||||||
|
shown[0] === 'Test Ridge Road Fire' &&
|
||||||
|
cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason.includes('below threshold') &&
|
||||||
|
cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason === 'outside area';
|
||||||
|
|
||||||
|
console.log('--- assertions ---');
|
||||||
|
console.log('only the in-area Emergency Warning shows:', shown.join(', ') || '(none)');
|
||||||
|
console.log('planned burn filtered by threshold:', cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason);
|
||||||
|
console.log('distant watch-and-act filtered by geofence:', cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason);
|
||||||
|
|
||||||
|
// lat/lon flip sanity: the screen point must NOT be found if we naively swap to lon,lat
|
||||||
|
const swapped = { lat: SCREEN.lon, lon: SCREEN.lat };
|
||||||
|
const ew = byLevel['Emergency Warning'];
|
||||||
|
console.log('flip guard (swapped coords should be OUTSIDE):', cap.pointInAlertArea(swapped, ew) ? 'FAIL (matched)' : 'ok (no match)');
|
||||||
|
|
||||||
|
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue