Add weather line: temp range + NWS severe alerts (hurricane/tornado/etc)

This commit is contained in:
James 2026-02-14 03:25:26 -05:00
parent 31c55cce3b
commit 8d8bbbc476
2 changed files with 80 additions and 13 deletions

View File

@ -22,12 +22,14 @@ body { background: #0a0a0a; color: #e0ddd5; font-family: 'Sora', sans-serif; ove
canvas#clock { width: 180px; height: 180px; } canvas#clock { width: 180px; height: 180px; }
#digital-time { text-align: center; color: #c8b273; font-size: 21px; font-weight: 300; letter-spacing: 2px; margin-top: 6px; font-variant-numeric: tabular-nums; } #digital-time { text-align: center; color: #c8b273; font-size: 21px; font-weight: 300; letter-spacing: 2px; margin-top: 6px; font-variant-numeric: tabular-nums; }
#digital-date { text-align: center; color: #666; font-size: 13px; font-weight: 400; letter-spacing: 1px; margin-top: 2px; } #digital-date { text-align: center; color: #666; font-size: 13px; font-weight: 400; letter-spacing: 1px; margin-top: 2px; }
#weather-line { text-align: center; font-size: 13px; font-weight: 300; color: #888; margin-top: 4px; letter-spacing: 1px; }
#weather-line .wx-alert { color: #ff2222; font-weight: 600; }
/* Cam + sensors row */ /* Cam + sensors row */
#cam-row { display: flex; align-items: center; gap: 14px; } #cam-row { display: flex; align-items: stretch; gap: 14px; }
/* Room sensors */ /* Room sensors */
#room-sensors { display: flex; flex-direction: column; gap: 12px; padding: 8px 16px; border-left: 1px solid #2a2a1a; } #room-sensors { display: flex; flex-direction: column; gap: 12px; padding: 12px 16px; border: 1px solid #222; border-radius: 8px; align-self: stretch; justify-content: center; }
#room-sensors .sensor { text-align: center; } #room-sensors .sensor { text-align: center; }
#room-sensors .sensor-val { font-size: 26px; font-weight: 300; color: #e0ddd5; font-variant-numeric: tabular-nums; line-height: 1.1; } #room-sensors .sensor-val { font-size: 26px; font-weight: 300; color: #e0ddd5; font-variant-numeric: tabular-nums; line-height: 1.1; }
#room-sensors .sensor-label { font-size: 9px; color: #c8b273; text-transform: uppercase; letter-spacing: 2px; font-weight: 600; } #room-sensors .sensor-label { font-size: 9px; color: #c8b273; text-transform: uppercase; letter-spacing: 2px; font-weight: 600; }
@ -97,6 +99,7 @@ canvas#clock { width: 180px; height: 180px; }
<div id="clock-wrap"><canvas id="clock" width="440" height="440"></canvas></div> <div id="clock-wrap"><canvas id="clock" width="440" height="440"></canvas></div>
<div id="digital-time"></div> <div id="digital-time"></div>
<div id="digital-date"></div> <div id="digital-date"></div>
<div id="weather-line"></div>
<div id="calendar"> <div id="calendar">
<div id="cal-nav"> <div id="cal-nav">
<button id="cal-prev"></button> <button id="cal-prev"></button>
@ -358,6 +361,21 @@ evtSource.onerror = () => { setTimeout(() => location.reload(), 5000); };
// Resume audio on first interaction // Resume audio on first interaction
document.addEventListener('click', () => { if (audioCtx.state === 'suspended') audioCtx.resume(); }, { once: true }); document.addEventListener('click', () => { if (audioCtx.state === 'suspended') audioCtx.resume(); }, { once: true });
// === WEATHER ===
async function updateWeather() {
try {
const r = await fetch('/api/weather');
const d = await r.json();
let html = `${d.low}${d.high}°F`;
if (d.alerts && d.alerts.length) {
html += ' <span class="wx-alert">⚠️ ' + d.alerts.join(', ') + '</span>';
}
document.getElementById('weather-line').innerHTML = html;
} catch(e) {}
}
updateWeather();
setInterval(updateWeather, 1800000);
// === ROOM SENSORS (7pm - 8am only) === // === ROOM SENSORS (7pm - 8am only) ===
const sensorEl = document.getElementById('room-sensors'); const sensorEl = document.getElementById('room-sensors');
@ -375,7 +393,7 @@ async function updateSensors() {
const tempF = parseFloat(d.temperature); const tempF = parseFloat(d.temperature);
const tempC = ((tempF - 32) * 5/9).toFixed(1); const tempC = ((tempF - 32) * 5/9).toFixed(1);
const tempEl = document.getElementById('s-temp'); const tempEl = document.getElementById('s-temp');
tempEl.textContent = Math.round(tempF) + '°'; tempEl.textContent = tempF.toFixed(1) + '°';
tempEl.title = tempC + '°C'; tempEl.title = tempC + '°C';
tempEl.className = 'sensor-val' + (tempF < 73 ? ' cold' : tempF > 76 ? ' crit' : ''); tempEl.className = 'sensor-val' + (tempF < 73 ? ' cold' : tempF > 76 ? ' crit' : '');
@ -392,19 +410,14 @@ async function updateSensors() {
} }
updateSensors(); updateSensors();
setInterval(updateSensors, 30000); setInterval(updateSensors, 5000);
// === PULSE-OX CAMERA (7pm - 8am only) === // === PULSE-OX CAMERA (7pm - 8am only) ===
const camImg = document.getElementById('pulse-cam'); const camImg = document.getElementById('pulse-cam');
const camWrap = document.getElementById('cam-wrap'); const camWrap = document.getElementById('cam-wrap');
function isCamTime() {
const h = new Date().getHours();
return h >= 19 || h < 8;
}
function updateCam() { function updateCam() {
if (isCamTime()) { if (isNightTime()) {
camWrap.style.display = ''; camWrap.style.display = '';
if (!camImg.src.includes('/stream')) { if (!camImg.src.includes('/stream')) {
camImg.src = '/api/cam/pulse-ox/stream'; camImg.src = '/api/cam/pulse-ox/stream';

View File

@ -61,8 +61,8 @@ app.post('/api/alerts', (req, res) => {
// Bedroom 1 sensors proxy from HA // Bedroom 1 sensors proxy from HA
const SENSOR_ENTITIES = { const SENSOR_ENTITIES = {
temperature: 'sensor.bed1_temperature', temperature: 'sensor.athom_tem_hum_sensor_b3882c_temperature',
humidity: 'sensor.bed1_humidity', humidity: 'sensor.athom_tem_hum_sensor_b3882c_humidity',
co2: 'sensor.athom_co2_sensor_b34780_co2' co2: 'sensor.athom_co2_sensor_b34780_co2'
}; };
@ -70,7 +70,7 @@ let sensorCache = { data: null, ts: 0 };
app.get('/api/sensors/bed1', async (req, res) => { app.get('/api/sensors/bed1', async (req, res) => {
// Cache for 15s to avoid hammering HA // Cache for 15s to avoid hammering HA
if (sensorCache.data && Date.now() - sensorCache.ts < 15000) { if (sensorCache.data && Date.now() - sensorCache.ts < 4000) {
return res.json(sensorCache.data); return res.json(sensorCache.data);
} }
try { try {
@ -96,6 +96,60 @@ app.get('/api/sensors/bed1', async (req, res) => {
} }
}); });
// Weather: temp range + severe alerts
let weatherCache = { data: null, ts: 0 };
const SEVERE_EVENTS = ['hurricane', 'tropical storm', 'tornado', 'severe thunderstorm', 'flash flood', 'storm surge', 'tsunami', 'extreme wind'];
app.get('/api/weather', async (req, res) => {
if (weatherCache.data && Date.now() - weatherCache.ts < 1800000) {
return res.json(weatherCache.data);
}
try {
const result = { high: null, low: null, alerts: [] };
// Temp range from wttr.in
const wttr = await new Promise((resolve, reject) => {
const r = require('https').get('https://wttr.in/St+Petersburg+FL?format=j1', {
headers: { 'User-Agent': 'james-dashboard' }
}, (resp) => {
let body = '';
resp.on('data', c => body += c);
resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
});
r.on('error', reject);
r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); });
});
const today = wttr.weather[0];
result.high = parseInt(today.maxtempF);
result.low = parseInt(today.mintempF);
// NWS severe alerts
const nws = await new Promise((resolve, reject) => {
const r = require('https').get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', {
headers: { 'User-Agent': 'james-dashboard' }
}, (resp) => {
let body = '';
resp.on('data', c => body += c);
resp.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
});
r.on('error', reject);
r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); });
});
for (const f of (nws.features || [])) {
const event = (f.properties.event || '').toLowerCase();
if (SEVERE_EVENTS.some(s => event.includes(s))) {
result.alerts.push(f.properties.event);
}
}
weatherCache = { data: result, ts: Date.now() };
res.json(result);
} catch(e) {
console.error('Weather fetch failed:', e.message);
res.status(502).json({ error: 'weather unavailable' });
}
});
// Camera proxy - snapshot from HA // Camera proxy - snapshot from HA
app.get('/api/cam/pulse-ox', (req, res) => { app.get('/api/cam/pulse-ox', (req, res) => {
const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, { const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, {