screentinker/Examples/PIP-USD-Cap-Alert-Monitor-NOAA/noaa-parse.js
screentinker 0b138f10c6
Add PiP overlay example recipes (#132)
Self-contained examples for the PiP overlay API (POST /api/pip), each
with a CSP-safe query-param overlay (external JS), config.example.json,
zero runtime deps, an offline test, and a README:

- PIP-Announce-Broadcast    manual one-shot message to a screen/group
- PIP-Weather-Widget        Open-Meteo current conditions (keyless)
- PIP-Air-Quality           Open-Meteo US AQI widget (keyless)
- PIP-Crypto-Ticker         CoinGecko price strip (keyless)
- PIP-News-Ticker           scrolling RSS/Atom headlines
- PIP-Room-Status-Calendar  ICS-driven Available/Busy room sign
- PIP-Event-Countdown       client-side countdown, auto-clears at zero
- PIP-Welcome-Board         rotating welcome/birthday cards from CSV
- PIP-Fundraiser-Thermometer goal-progress bar from local/URL JSON
- PIP-QR-Rotator            rotating QR codes, encoded client-side
- PIP-Incident-Webhook      event-driven: red on firing, clear on resolved

Also includes the CAP-AU (NSW RFS) and US NWS/NOAA emergency-alert
monitors that push expiry-aware PiP overlays.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:20:37 -05:00

104 lines
4.6 KiB
JavaScript

'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,
};