291 lines
9.9 KiB
JavaScript
291 lines
9.9 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
const PORT = 9202;
|
|
const ALERTS_FILE = path.join(__dirname, 'alerts.json');
|
|
|
|
app.use(express.json());
|
|
|
|
// Load/save alerts
|
|
function loadAlerts() {
|
|
try { return JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')); }
|
|
catch { return []; }
|
|
}
|
|
function saveAlerts(alerts) {
|
|
fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2));
|
|
}
|
|
|
|
const http = require('http');
|
|
|
|
// Camera proxy config
|
|
const HA_URL = 'http://192.168.1.252:8123';
|
|
const HA_TOKEN = process.env.HA_TOKEN || '';
|
|
|
|
// SSE clients
|
|
const sseClients = new Set();
|
|
|
|
// Serve dashboard
|
|
app.get('/', (req, res) => {
|
|
res.set('Cache-Control', 'no-store');
|
|
res.sendFile(path.join(__dirname, 'index.html'));
|
|
});
|
|
|
|
// List alerts
|
|
app.get('/api/alerts', (req, res) => {
|
|
res.json(loadAlerts().slice(-50).reverse());
|
|
});
|
|
|
|
// Push alert
|
|
app.post('/api/alerts', (req, res) => {
|
|
const { message, priority = 'info' } = req.body;
|
|
if (!message) return res.status(400).json({ error: 'message required' });
|
|
const alert = {
|
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
message,
|
|
priority,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
const alerts = loadAlerts();
|
|
alerts.push(alert);
|
|
// Keep last 100
|
|
if (alerts.length > 100) alerts.splice(0, alerts.length - 100);
|
|
saveAlerts(alerts);
|
|
// Notify SSE clients
|
|
for (const client of sseClients) {
|
|
client.write(`data: ${JSON.stringify(alert)}\n\n`);
|
|
}
|
|
res.status(201).json(alert);
|
|
});
|
|
|
|
// Bedroom 1 sensors proxy from HA
|
|
const SENSOR_ENTITIES = {
|
|
temperature: 'sensor.athom_tem_hum_sensor_b3882c_temperature',
|
|
humidity: 'sensor.athom_tem_hum_sensor_b3882c_humidity',
|
|
co2: 'sensor.athom_co2_sensor_b34780_co2'
|
|
};
|
|
|
|
let sensorCache = { data: null, ts: 0 };
|
|
|
|
app.get('/api/sensors/bed1', async (req, res) => {
|
|
// Cache for 15s to avoid hammering HA
|
|
if (sensorCache.data && Date.now() - sensorCache.ts < 4000) {
|
|
return res.json(sensorCache.data);
|
|
}
|
|
try {
|
|
const results = {};
|
|
for (const [key, entity] of Object.entries(SENSOR_ENTITIES)) {
|
|
const data = await new Promise((resolve, reject) => {
|
|
const r = http.get(`${HA_URL}/api/states/${entity}`, {
|
|
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
|
}, (haRes) => {
|
|
let body = '';
|
|
haRes.on('data', c => body += c);
|
|
haRes.on('end', () => { try { resolve(JSON.parse(body)); } catch(e) { reject(e); } });
|
|
});
|
|
r.on('error', reject);
|
|
r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
|
|
});
|
|
results[key] = data.state;
|
|
}
|
|
sensorCache = { data: results, ts: Date.now() };
|
|
res.json(results);
|
|
} catch(e) {
|
|
res.status(502).json({ error: 'HA unavailable' });
|
|
}
|
|
});
|
|
|
|
// 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, current: null, alerts: [] };
|
|
|
|
// Current outside temp from HA
|
|
try {
|
|
const haData = await new Promise((resolve, reject) => {
|
|
const r = http.get(`${HA_URL}/api/states/sensor.air_temp`, {
|
|
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
|
}, (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(3000, () => { r.destroy(); reject(new Error('timeout')); });
|
|
});
|
|
result.current = parseInt(haData.state);
|
|
} catch(e) {}
|
|
|
|
// 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 (imminent)
|
|
try {
|
|
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);
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
|
|
// NHC Tropical Weather Outlook (7-day formation, hurricane season)
|
|
try {
|
|
const twoHtml = await new Promise((resolve, reject) => {
|
|
const r = require('https').get('https://www.nhc.noaa.gov/text/MIATWOAT.shtml', {
|
|
headers: { 'User-Agent': 'james-dashboard' }
|
|
}, (resp) => {
|
|
let body = '';
|
|
resp.on('data', c => body += c);
|
|
resp.on('end', () => resolve(body));
|
|
});
|
|
r.on('error', reject);
|
|
r.setTimeout(10000, () => { r.destroy(); reject(new Error('timeout')); });
|
|
});
|
|
const preMatch = twoHtml.match(/<pre>([\s\S]*?)<\/pre>/);
|
|
if (preMatch) {
|
|
const text = preMatch[1].replace(/<[^>]+>/g, '');
|
|
// Skip if off-season / "not expected"
|
|
if (!text.includes('formation is not expected')) {
|
|
// Extract formation probabilities
|
|
const lines = text.split('\n');
|
|
const formations = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const pctMatch = lines[i].match(/(\d+)\s*percent/i);
|
|
if (pctMatch && parseInt(pctMatch[1]) >= 20) {
|
|
// Find nearby context about Gulf/Caribbean
|
|
const context = lines.slice(Math.max(0, i-3), i+2).join(' ');
|
|
const loc = context.match(/Gulf|Caribbean|Atlantic|Florida|Bahamas/i);
|
|
formations.push({
|
|
pct: parseInt(pctMatch[1]),
|
|
location: loc ? loc[0] : 'Atlantic',
|
|
text: lines[i].trim()
|
|
});
|
|
}
|
|
}
|
|
if (formations.length) {
|
|
const worst = formations.sort((a,b) => b.pct - a.pct)[0];
|
|
result.tropical = `${worst.pct}% tropical development (${worst.location}, 7-day)`;
|
|
}
|
|
}
|
|
}
|
|
} catch(e) {}
|
|
|
|
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
|
|
app.get('/api/cam/pulse-ox', (req, res) => {
|
|
const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, {
|
|
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
|
}, (haRes) => {
|
|
res.set('Content-Type', haRes.headers['content-type'] || 'image/jpeg');
|
|
res.set('Cache-Control', 'no-store');
|
|
haRes.pipe(res);
|
|
});
|
|
haReq.on('error', () => res.status(502).end());
|
|
haReq.setTimeout(5000, () => { haReq.destroy(); res.status(504).end(); });
|
|
});
|
|
|
|
// Camera proxy - MJPEG stream from HA
|
|
app.get('/api/cam/pulse-ox/stream', (req, res) => {
|
|
const haReq = http.get(`${HA_URL}/api/camera_proxy_stream/camera.pulse_ox_live_view`, {
|
|
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
|
}, (haRes) => {
|
|
res.set('Content-Type', haRes.headers['content-type'] || 'multipart/x-mixed-replace; boundary=frame');
|
|
res.set('Cache-Control', 'no-store');
|
|
haRes.pipe(res);
|
|
req.on('close', () => haRes.destroy());
|
|
});
|
|
haReq.on('error', () => res.status(502).end());
|
|
});
|
|
|
|
// Toggle done
|
|
app.patch('/api/alerts/:id/done', (req, res) => {
|
|
const alerts = loadAlerts();
|
|
const alert = alerts.find(a => a.id === req.params.id);
|
|
if (!alert) return res.status(404).json({ error: 'not found' });
|
|
alert.done = !!req.body.done;
|
|
saveAlerts(alerts);
|
|
res.json(alert);
|
|
});
|
|
|
|
// Delete alert
|
|
app.delete('/api/alerts/:id', (req, res) => {
|
|
const alerts = loadAlerts();
|
|
const idx = alerts.findIndex(a => a.id === req.params.id);
|
|
if (idx === -1) return res.status(404).json({ error: 'not found' });
|
|
alerts.splice(idx, 1);
|
|
saveAlerts(alerts);
|
|
// Notify SSE clients of removal
|
|
for (const client of sseClients) {
|
|
client.write(`data: ${JSON.stringify({ type: 'remove', id: req.params.id })}\n\n`);
|
|
}
|
|
res.json({ status: 'deleted' });
|
|
});
|
|
|
|
// SSE stream
|
|
app.get('/api/alerts/stream', (req, res) => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
});
|
|
res.write('data: {"type":"connected"}\n\n');
|
|
sseClients.add(res);
|
|
req.on('close', () => sseClients.delete(res));
|
|
});
|
|
|
|
// Auto-purge done items older than 2 hours
|
|
setInterval(() => {
|
|
const alerts = loadAlerts();
|
|
const cutoff = Date.now() - 2 * 3600000;
|
|
const filtered = alerts.filter(a => !(a.done && new Date(a.timestamp).getTime() < cutoff));
|
|
if (filtered.length < alerts.length) {
|
|
saveAlerts(filtered);
|
|
for (const client of sseClients) {
|
|
client.write(`data: ${JSON.stringify({ type: 'refresh' })}\n\n`);
|
|
}
|
|
}
|
|
}, 300000); // Check every 5 min
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
|
|
});
|