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.bed1_temperature', humidity: 'sensor.bed1_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 < 15000) { 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' }); } }); // 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}`); });