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', group } = 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, group: group || null, 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) => { // Refresh current temp from NWS every request, forecast/NHC daily const forecastFresh = weatherCache.data && Date.now() - weatherCache.ts < 86400000; if (forecastFresh) { // Just update current temp from NWS try { const nwsData = await new Promise((resolve, reject) => { const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', { 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(5000, () => { r.destroy(); reject(new Error('timeout')); }); }); // Convert Celsius to Fahrenheit const tempC = nwsData.properties.temperature.value; if (tempC !== null) { weatherCache.data.current = Math.round(tempC * 9/5 + 32); weatherCache.data.currentC = Math.round(tempC); } } catch(e) {} return res.json(weatherCache.data); } try { const result = { high: null, low: null, current: null, currentC: null, alerts: [] }; // Current outside temp from NWS try { const nwsData = await new Promise((resolve, reject) => { const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', { 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(5000, () => { r.destroy(); reject(new Error('timeout')); }); }); // Convert Celsius to Fahrenheit const tempC = nwsData.properties.temperature.value; if (tempC !== null) { result.current = Math.round(tempC * 9/5 + 32); result.currentC = Math.round(tempC); } } 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(/
([\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
// Meeting storage
let nextMeeting = null;
// Claude usage endpoint
app.get('/api/claude-usage', async (req, res) => {
try {
const jamesData = await new Promise((resolve, reject) => {
const r = http.get('http://localhost:9200/api/status', (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')); });
});
// James dashboard returns {status: {key: {value: "..."}, ...}}
const statuses = jamesData.status || jamesData;
const usage = statuses['claude-usage'] || statuses['claude'];
// Extract usage percentage from value like "๐ Weekly: 46% used" or "46% used ยท 2:00 PM"
let usagePct = null;
if (usage && usage.value) {
const m = usage.value.match(/(\d+)%/);
if (m) usagePct = parseInt(m[1]);
}
// Calculate time% into budget week (Sat 2pm - Sat 2pm ET)
const now = new Date();
// Find most recent Saturday 2pm ET
const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const day = et.getDay(); // 0=Sun
// Days since last Saturday
const daysSinceSat = day === 6 ? 0 : day + 1;
const lastSat = new Date(et);
lastSat.setDate(lastSat.getDate() - daysSinceSat);
lastSat.setHours(14, 0, 0, 0); // 2pm
// If we're before 2pm on Saturday, go back one more week
if (et < lastSat) {
lastSat.setDate(lastSat.getDate() - 7);
}
const weekMs = 7 * 24 * 60 * 60 * 1000;
const elapsed = et - lastSat;
const timePct = Math.min(100, Math.max(0, (elapsed / weekMs) * 100));
// Burn rate = usage% / time% โ >100 means overspending
let pace = null;
if (timePct > 0) {
pace = Math.round((usagePct / timePct) * 100);
}
res.json({ usage: usagePct, timePct: Math.round(timePct), pace });
} catch(e) {
res.status(502).json({ error: 'james dashboard unavailable' });
}
});
// Meeting endpoints
app.get('/api/meeting', (req, res) => {
if (!nextMeeting) return res.json(null);
const now = new Date();
const meetingTime = new Date(nextMeeting.time);
// Remove if in the past
if (meetingTime < now) {
nextMeeting = null;
return res.json(null);
}
res.json(nextMeeting);
});
app.post('/api/meeting', (req, res) => {
const { title, time, id } = req.body;
if (!title || !time) return res.status(400).json({ error: 'title and time required' });
nextMeeting = { title, time, id: id || Date.now().toString(36) };
res.status(201).json(nextMeeting);
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
});