581 lines
23 KiB
JavaScript
581 lines
23 KiB
JavaScript
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();
|
||
// Week resets Thu 10 PM ET (changed from Sat 2PM — 2026-02-28)
|
||
const daysSinceThu = (day + 3) % 7;
|
||
const lastThu = new Date(et);
|
||
lastThu.setDate(lastThu.getDate() - daysSinceThu);
|
||
lastThu.setHours(22, 0, 0, 0);
|
||
if (et < lastThu) lastThu.setDate(lastThu.getDate() - 7);
|
||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||
const usableMs = weekMs;
|
||
const elapsed = et - lastThu;
|
||
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)}…`);
|
||
});
|