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/.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(/
([\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 7AM–2PM (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)}…`);
});