alert-dashboard/server.js

582 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const app = express();
const PORT = 9202;
const ALERTS_FILE = path.join(__dirname, 'alerts.json');
app.use(express.json());
// ─── Fish Audio TTS Config ───────────────────────────────────────────────────
const FISH_API_KEY = (() => {
try {
const envFile = fs.readFileSync('/home/johan/.config/fish-audio.env', 'utf8');
const match = envFile.match(/FISH_API_KEY\s*=\s*(.+)/);
if (match) return match[1].trim();
} catch (e) {}
return process.env.FISH_API_KEY || 'd50ba0bcfce34d918f875266272325c7';
})();
const FISH_VOICE_REF = 'bf322df2096a46f18c579d0baa36f41d';
const FULLY_BASE = 'http://192.168.2.243:2323';
const FULLY_PWD = '3005';
const SERVER_IP = '192.168.1.16'; // forge — reachable from tablet
// ─── TTS Directory ───────────────────────────────────────────────────────────
const TTS_DIR = path.join(__dirname, 'tts');
if (!fs.existsSync(TTS_DIR)) fs.mkdirSync(TTS_DIR, { recursive: true });
// ─── 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));
}
// 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();
// ─── Night hours check (11pm 8am ET) ──────────────────────────────────────
function isNightHours() {
const hour = parseInt(
new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
hour12: false,
timeZone: 'America/New_York'
}).format(new Date())
);
return hour >= 23 || hour < 8;
}
// ─── Send a command to Fully Kiosk ───────────────────────────────────────────
function fullyCmd(params) {
return new Promise((resolve) => {
const qs = new URLSearchParams({ ...params, password: FULLY_PWD });
const url = `${FULLY_BASE}/?${qs.toString()}`;
const req = http.get(url, (res) => {
res.resume();
res.on('end', resolve);
});
req.on('error', (e) => {
console.error('[Fully] Error:', e.message);
resolve(); // non-critical — don't crash
});
req.setTimeout(5000, () => { req.destroy(); resolve(); });
});
}
// ─── Generate TTS via Fish Audio, save to /tts/<uuid>.mp3 ───────────────────
function generateTTS(text) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
text,
reference_id: FISH_VOICE_REF,
format: 'mp3',
mp3_bitrate: 128,
normalize: true,
latency: 'normal'
});
const options = {
hostname: 'api.fish.audio',
path: '/v1/tts',
method: 'POST',
headers: {
'Authorization': `Bearer ${FISH_API_KEY}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}
};
const filename = `${crypto.randomUUID()}.mp3`;
const filepath = path.join(TTS_DIR, filename);
const fileStream = fs.createWriteStream(filepath);
const req = https.request(options, (res) => {
if (res.statusCode !== 200) {
let errBody = '';
res.on('data', c => errBody += c);
res.on('end', () => {
fs.unlink(filepath, () => {});
reject(new Error(`Fish API ${res.statusCode}: ${errBody}`));
});
return;
}
res.pipe(fileStream);
fileStream.on('finish', () => {
console.log(`[TTS] Generated: ${filename} (${text.slice(0, 60)}…)`);
resolve(filename);
});
fileStream.on('error', reject);
});
req.on('error', reject);
req.setTimeout(30000, () => { req.destroy(); reject(new Error('Fish API timeout')); });
req.write(body);
req.end();
});
}
// ─── Play an mp3 file on the office Fully tablet ────────────────────────────
async function playOnTablet(filename) {
if (isNightHours()) {
console.log('[TTS] Night hours — skipping playback');
return;
}
const mp3Url = `http://${SERVER_IP}:${PORT}/tts/${filename}`;
console.log('[TTS] Playing on tablet:', mp3Url);
await fullyCmd({ cmd: 'screenOn' });
await new Promise(r => setTimeout(r, 400)); // brief gap for screen
await fullyCmd({ cmd: 'playSound', url: mp3Url });
}
// ─── Cleanup TTS files older than 1 hour ─────────────────────────────────────
setInterval(() => {
try {
const cutoff = Date.now() - 3600000;
for (const f of fs.readdirSync(TTS_DIR)) {
if (!f.endsWith('.mp3')) continue;
const stat = fs.statSync(path.join(TTS_DIR, f));
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(path.join(TTS_DIR, f));
console.log('[TTS] Cleaned up:', f);
}
}
} catch (e) {}
}, 600_000); // every 10 min
// ─── Static route for TTS files ──────────────────────────────────────────────
app.use('/tts', express.static(TTS_DIR));
// ─── 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 (with TTS for critical) ──────────────────────────────────────
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);
if (alerts.length > 100) alerts.splice(0, alerts.length - 100);
saveAlerts(alerts);
for (const client of sseClients) {
client.write(`data: ${JSON.stringify(alert)}\n\n`);
}
// Critical alert → voice announcement
if (priority === 'critical') {
(async () => {
try {
console.log('[Alert TTS] Critical alert — generating voice');
const filename = await generateTTS(message);
await playOnTablet(filename);
} catch (e) {
console.error('[Alert TTS] Failed:', e.message);
}
})();
}
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) => {
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 ─────────────────────────────────────────────────────────────────
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) => {
const forecastFresh = weatherCache.data && Date.now() - weatherCache.ts < 86400000;
if (forecastFresh) {
try {
const nwsData = await new Promise((resolve, reject) => {
const r = 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')); });
});
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: [] };
try {
const nwsData = await new Promise((resolve, reject) => {
const r = 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')); });
});
const tempC = nwsData.properties.temperature.value;
if (tempC !== null) {
result.current = Math.round(tempC * 9/5 + 32);
result.currentC = Math.round(tempC);
}
} catch(e) {}
const wttr = await new Promise((resolve, reject) => {
const r = 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);
try {
const nws = await new Promise((resolve, reject) => {
const r = 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) {}
try {
const twoHtml = await new Promise((resolve, reject) => {
const r = 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, '');
if (!text.includes('formation is not expected')) {
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) {
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 ─────────────────────────────────────────────────────────────
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(); });
});
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);
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`);
}
}
}, 300_000);
// ─── 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')); });
});
const statuses = jamesData.status || jamesData;
const usage = statuses['claude-usage'] || statuses['claude'];
let usagePct = null;
if (usage && usage.value) {
const m = usage.value.match(/(\d+)%/);
if (m) usagePct = parseInt(m[1]);
}
const now = new Date();
const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
const day = et.getDay();
const daysSinceSat = day === 6 ? 0 : day + 1;
const lastSat = new Date(et);
lastSat.setDate(lastSat.getDate() - daysSinceSat);
lastSat.setHours(14, 0, 0, 0);
if (et < lastSat) lastSat.setDate(lastSat.getDate() - 7);
const weekMs = 7 * 24 * 60 * 60 * 1000;
// Exclude Sat 7AM2PM (7h dead zone — Johan asleep, can't use Claude)
// Effective usable week = 168h - 7h = 161h, ending Sat 7AM instead of 2PM
const usableMs = 161 * 60 * 60 * 1000;
const elapsed = et - lastSat;
const effElapsed = Math.min(Math.max(0, elapsed), usableMs);
const timePct = Math.min(100, (effElapsed / usableMs) * 100);
const pace = timePct > 0 ? Math.round((usagePct / timePct) * 100) : null;
res.json({ usage: usagePct, timePct: Math.round(timePct), pace });
} catch(e) {
res.status(502).json({ error: 'james dashboard unavailable' });
}
});
// ─── Meeting state ────────────────────────────────────────────────────────────
let nextMeeting = null;
let meetingTimer = null;
let meetingTTSFile = null;
// Build the voice message text for a meeting
function buildMeetingTTSText(meeting) {
const { title, organizer, topic } = meeting;
let text = `You have ${title} in 5 minutes`;
if (organizer) text += `, organized by ${organizer}`;
else if (topic) text += `. Topic: ${topic}`;
text += '.';
return text;
}
// Schedule pre-generation and playback timer
async function scheduleMeetingAnnouncement(meeting) {
const meetingTime = new Date(meeting.time);
const msUntilMeeting = meetingTime - Date.now();
const msUntilAnnounce = msUntilMeeting - 5 * 60 * 1000; // T-5 min
const ttsText = buildMeetingTTSText(meeting);
// Pre-generate audio now, so playback is instant
console.log('[Meeting] Pre-generating TTS for:', meeting.title);
try {
meetingTTSFile = await generateTTS(ttsText);
console.log('[Meeting] TTS ready:', meetingTTSFile);
} catch (e) {
console.error('[Meeting] TTS generation failed:', e.message);
meetingTTSFile = null;
}
const announce = async () => {
console.log('[Meeting] T-5 announcement for:', meeting.title);
if (meetingTTSFile) {
try { await playOnTablet(meetingTTSFile); }
catch (e) { console.error('[Meeting] Playback failed:', e.message); }
}
};
if (msUntilAnnounce > 0) {
meetingTimer = setTimeout(announce, msUntilAnnounce);
console.log(`[Meeting] Timer set — ${Math.round(msUntilAnnounce / 1000)}s until announcement`);
} else if (msUntilMeeting > 0) {
// Already within 5 min window — announce immediately
console.log('[Meeting] Already within 5-min window — announcing now');
await announce();
} else {
console.log('[Meeting] Meeting is in the past — skipping announcement');
}
}
// ─── Get current meeting ──────────────────────────────────────────────────────
app.get('/api/meeting', (req, res) => {
if (!nextMeeting) return res.json(null);
if (new Date(nextMeeting.time) < new Date()) {
nextMeeting = null;
return res.json(null);
}
res.json(nextMeeting);
});
// ─── Set next meeting (with voice announcement scheduling) ───────────────────
app.post('/api/meeting', (req, res) => {
const { title, time, id, organizer, topic } = req.body;
if (!title || !time) return res.status(400).json({ error: 'title and time required' });
// Cancel any existing timer
if (meetingTimer) {
clearTimeout(meetingTimer);
meetingTimer = null;
meetingTTSFile = null;
}
nextMeeting = { title, time, id: id || Date.now().toString(36), organizer, topic };
res.status(201).json(nextMeeting);
// Kick off async pre-gen + scheduling (don't await — respond immediately)
scheduleMeetingAnnouncement(nextMeeting).catch(e =>
console.error('[Meeting] Scheduler error:', e.message)
);
});
// ─── Start server ─────────────────────────────────────────────────────────────
app.listen(PORT, '0.0.0.0', () => {
console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
console.log(`Fish TTS: key=${FISH_API_KEY.slice(0, 8)}… voice=${FISH_VOICE_REF.slice(0, 8)}`);
});