feat: voice meeting announcements via Fish Audio TTS + Fully Kiosk
- Add Fish Audio TTS integration (POST /v1/tts, Adrian voice)
- Pre-generate meeting audio at push time (instant playback at T-5)
- Schedule T-5 minute announcement timer on POST /api/meeting
- Play audio on office Fully tablet via REST API (screenOn + playSound)
- Critical alerts also trigger voice playback
- Serve mp3s from /tts/ static route
- Auto-cleanup TTS files older than 1 hour
- Night hours guard (11pm–8am ET, no playback)
- Fish API key loaded from /home/johan/.config/fish-audio.env or env var
- Inline require('https') → top-level import throughout
This commit is contained in:
parent
baf49db235
commit
bbf946af5d
342
server.js
342
server.js
|
|
@ -1,6 +1,9 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 9202;
|
const PORT = 9202;
|
||||||
|
|
@ -8,7 +11,26 @@ const ALERTS_FILE = path.join(__dirname, 'alerts.json');
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Load/save alerts
|
// ─── 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() {
|
function loadAlerts() {
|
||||||
try { return JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')); }
|
try { return JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8')); }
|
||||||
catch { return []; }
|
catch { return []; }
|
||||||
|
|
@ -17,30 +39,141 @@ function saveAlerts(alerts) {
|
||||||
fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2));
|
fs.writeFileSync(ALERTS_FILE, JSON.stringify(alerts, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
// Camera proxy config
|
// Camera proxy config
|
||||||
const HA_URL = 'http://192.168.1.252:8123';
|
const HA_URL = 'http://192.168.1.252:8123';
|
||||||
const HA_TOKEN = process.env.HA_TOKEN || '';
|
const HA_TOKEN = process.env.HA_TOKEN || '';
|
||||||
|
|
||||||
// SSE clients
|
// SSE clients
|
||||||
const sseClients = new Set();
|
const sseClients = new Set();
|
||||||
|
|
||||||
// Serve dashboard
|
// ─── 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) => {
|
app.get('/', (req, res) => {
|
||||||
res.set('Cache-Control', 'no-store');
|
res.set('Cache-Control', 'no-store');
|
||||||
res.sendFile(path.join(__dirname, 'index.html'));
|
res.sendFile(path.join(__dirname, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// List alerts
|
// ─── List alerts ─────────────────────────────────────────────────────────────
|
||||||
app.get('/api/alerts', (req, res) => {
|
app.get('/api/alerts', (req, res) => {
|
||||||
res.json(loadAlerts().slice(-50).reverse());
|
res.json(loadAlerts().slice(-50).reverse());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Push alert
|
// ─── Push alert (with TTS for critical) ──────────────────────────────────────
|
||||||
app.post('/api/alerts', (req, res) => {
|
app.post('/api/alerts', (req, res) => {
|
||||||
const { message, priority = 'info', group } = req.body;
|
const { message, priority = 'info', group } = req.body;
|
||||||
if (!message) return res.status(400).json({ error: 'message required' });
|
if (!message) return res.status(400).json({ error: 'message required' });
|
||||||
|
|
||||||
const alert = {
|
const alert = {
|
||||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
||||||
message,
|
message,
|
||||||
|
|
@ -48,29 +181,42 @@ app.post('/api/alerts', (req, res) => {
|
||||||
group: group || null,
|
group: group || null,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
const alerts = loadAlerts();
|
const alerts = loadAlerts();
|
||||||
alerts.push(alert);
|
alerts.push(alert);
|
||||||
// Keep last 100
|
|
||||||
if (alerts.length > 100) alerts.splice(0, alerts.length - 100);
|
if (alerts.length > 100) alerts.splice(0, alerts.length - 100);
|
||||||
saveAlerts(alerts);
|
saveAlerts(alerts);
|
||||||
// Notify SSE clients
|
|
||||||
for (const client of sseClients) {
|
for (const client of sseClients) {
|
||||||
client.write(`data: ${JSON.stringify(alert)}\n\n`);
|
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);
|
res.status(201).json(alert);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bedroom 1 sensors proxy from HA
|
// ─── Bedroom 1 sensors proxy from HA ─────────────────────────────────────────
|
||||||
const SENSOR_ENTITIES = {
|
const SENSOR_ENTITIES = {
|
||||||
temperature: 'sensor.athom_tem_hum_sensor_b3882c_temperature',
|
temperature: 'sensor.athom_tem_hum_sensor_b3882c_temperature',
|
||||||
humidity: 'sensor.athom_tem_hum_sensor_b3882c_humidity',
|
humidity: 'sensor.athom_tem_hum_sensor_b3882c_humidity',
|
||||||
co2: 'sensor.athom_co2_sensor_b34780_co2'
|
co2: 'sensor.athom_co2_sensor_b34780_co2'
|
||||||
};
|
};
|
||||||
|
|
||||||
let sensorCache = { data: null, ts: 0 };
|
let sensorCache = { data: null, ts: 0 };
|
||||||
|
|
||||||
app.get('/api/sensors/bed1', async (req, res) => {
|
app.get('/api/sensors/bed1', async (req, res) => {
|
||||||
// Cache for 15s to avoid hammering HA
|
|
||||||
if (sensorCache.data && Date.now() - sensorCache.ts < 4000) {
|
if (sensorCache.data && Date.now() - sensorCache.ts < 4000) {
|
||||||
return res.json(sensorCache.data);
|
return res.json(sensorCache.data);
|
||||||
}
|
}
|
||||||
|
|
@ -97,18 +243,16 @@ app.get('/api/sensors/bed1', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Weather: temp range + severe alerts
|
// ─── Weather ─────────────────────────────────────────────────────────────────
|
||||||
let weatherCache = { data: null, ts: 0 };
|
let weatherCache = { data: null, ts: 0 };
|
||||||
const SEVERE_EVENTS = ['hurricane', 'tropical storm', 'tornado', 'severe thunderstorm', 'flash flood', 'storm surge', 'tsunami', 'extreme wind'];
|
const SEVERE_EVENTS = ['hurricane', 'tropical storm', 'tornado', 'severe thunderstorm', 'flash flood', 'storm surge', 'tsunami', 'extreme wind'];
|
||||||
|
|
||||||
app.get('/api/weather', async (req, res) => {
|
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;
|
const forecastFresh = weatherCache.data && Date.now() - weatherCache.ts < 86400000;
|
||||||
if (forecastFresh) {
|
if (forecastFresh) {
|
||||||
// Just update current temp from NWS
|
|
||||||
try {
|
try {
|
||||||
const nwsData = await new Promise((resolve, reject) => {
|
const nwsData = await new Promise((resolve, reject) => {
|
||||||
const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', {
|
const r = https.get('https://api.weather.gov/stations/KSPG/observations/latest', {
|
||||||
headers: { 'User-Agent': 'james-dashboard' }
|
headers: { 'User-Agent': 'james-dashboard' }
|
||||||
}, (resp) => {
|
}, (resp) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
@ -118,10 +262,9 @@ app.get('/api/weather', async (req, res) => {
|
||||||
r.on('error', reject);
|
r.on('error', reject);
|
||||||
r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); });
|
r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); });
|
||||||
});
|
});
|
||||||
// Convert Celsius to Fahrenheit
|
|
||||||
const tempC = nwsData.properties.temperature.value;
|
const tempC = nwsData.properties.temperature.value;
|
||||||
if (tempC !== null) {
|
if (tempC !== null) {
|
||||||
weatherCache.data.current = Math.round(tempC * 9/5 + 32);
|
weatherCache.data.current = Math.round(tempC * 9/5 + 32);
|
||||||
weatherCache.data.currentC = Math.round(tempC);
|
weatherCache.data.currentC = Math.round(tempC);
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
@ -130,10 +273,9 @@ app.get('/api/weather', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = { high: null, low: null, current: null, currentC: null, alerts: [] };
|
const result = { high: null, low: null, current: null, currentC: null, alerts: [] };
|
||||||
|
|
||||||
// Current outside temp from NWS
|
|
||||||
try {
|
try {
|
||||||
const nwsData = await new Promise((resolve, reject) => {
|
const nwsData = await new Promise((resolve, reject) => {
|
||||||
const r = require('https').get('https://api.weather.gov/stations/KSPG/observations/latest', {
|
const r = https.get('https://api.weather.gov/stations/KSPG/observations/latest', {
|
||||||
headers: { 'User-Agent': 'james-dashboard' }
|
headers: { 'User-Agent': 'james-dashboard' }
|
||||||
}, (resp) => {
|
}, (resp) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
@ -143,17 +285,15 @@ app.get('/api/weather', async (req, res) => {
|
||||||
r.on('error', reject);
|
r.on('error', reject);
|
||||||
r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); });
|
r.setTimeout(5000, () => { r.destroy(); reject(new Error('timeout')); });
|
||||||
});
|
});
|
||||||
// Convert Celsius to Fahrenheit
|
|
||||||
const tempC = nwsData.properties.temperature.value;
|
const tempC = nwsData.properties.temperature.value;
|
||||||
if (tempC !== null) {
|
if (tempC !== null) {
|
||||||
result.current = Math.round(tempC * 9/5 + 32);
|
result.current = Math.round(tempC * 9/5 + 32);
|
||||||
result.currentC = Math.round(tempC);
|
result.currentC = Math.round(tempC);
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
// Temp range from wttr.in
|
|
||||||
const wttr = await new Promise((resolve, reject) => {
|
const wttr = await new Promise((resolve, reject) => {
|
||||||
const r = require('https').get('https://wttr.in/St+Petersburg+FL?format=j1', {
|
const r = https.get('https://wttr.in/St+Petersburg+FL?format=j1', {
|
||||||
headers: { 'User-Agent': 'james-dashboard' }
|
headers: { 'User-Agent': 'james-dashboard' }
|
||||||
}, (resp) => {
|
}, (resp) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
@ -165,12 +305,11 @@ app.get('/api/weather', async (req, res) => {
|
||||||
});
|
});
|
||||||
const today = wttr.weather[0];
|
const today = wttr.weather[0];
|
||||||
result.high = parseInt(today.maxtempF);
|
result.high = parseInt(today.maxtempF);
|
||||||
result.low = parseInt(today.mintempF);
|
result.low = parseInt(today.mintempF);
|
||||||
|
|
||||||
// NWS severe alerts (imminent)
|
|
||||||
try {
|
try {
|
||||||
const nws = await new Promise((resolve, reject) => {
|
const nws = await new Promise((resolve, reject) => {
|
||||||
const r = require('https').get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', {
|
const r = https.get('https://api.weather.gov/alerts/active?point=27.7676,-82.6403', {
|
||||||
headers: { 'User-Agent': 'james-dashboard' }
|
headers: { 'User-Agent': 'james-dashboard' }
|
||||||
}, (resp) => {
|
}, (resp) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
@ -188,10 +327,9 @@ app.get('/api/weather', async (req, res) => {
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
// NHC Tropical Weather Outlook (7-day formation, hurricane season)
|
|
||||||
try {
|
try {
|
||||||
const twoHtml = await new Promise((resolve, reject) => {
|
const twoHtml = await new Promise((resolve, reject) => {
|
||||||
const r = require('https').get('https://www.nhc.noaa.gov/text/MIATWOAT.shtml', {
|
const r = https.get('https://www.nhc.noaa.gov/text/MIATWOAT.shtml', {
|
||||||
headers: { 'User-Agent': 'james-dashboard' }
|
headers: { 'User-Agent': 'james-dashboard' }
|
||||||
}, (resp) => {
|
}, (resp) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|
@ -204,15 +342,12 @@ app.get('/api/weather', async (req, res) => {
|
||||||
const preMatch = twoHtml.match(/<pre>([\s\S]*?)<\/pre>/);
|
const preMatch = twoHtml.match(/<pre>([\s\S]*?)<\/pre>/);
|
||||||
if (preMatch) {
|
if (preMatch) {
|
||||||
const text = preMatch[1].replace(/<[^>]+>/g, '');
|
const text = preMatch[1].replace(/<[^>]+>/g, '');
|
||||||
// Skip if off-season / "not expected"
|
|
||||||
if (!text.includes('formation is not expected')) {
|
if (!text.includes('formation is not expected')) {
|
||||||
// Extract formation probabilities
|
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
const formations = [];
|
const formations = [];
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const pctMatch = lines[i].match(/(\d+)\s*percent/i);
|
const pctMatch = lines[i].match(/(\d+)\s*percent/i);
|
||||||
if (pctMatch && parseInt(pctMatch[1]) >= 20) {
|
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 context = lines.slice(Math.max(0, i-3), i+2).join(' ');
|
||||||
const loc = context.match(/Gulf|Caribbean|Atlantic|Florida|Bahamas/i);
|
const loc = context.match(/Gulf|Caribbean|Atlantic|Florida|Bahamas/i);
|
||||||
formations.push({
|
formations.push({
|
||||||
|
|
@ -238,7 +373,7 @@ app.get('/api/weather', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Camera proxy - snapshot from HA
|
// ─── Camera proxy ─────────────────────────────────────────────────────────────
|
||||||
app.get('/api/cam/pulse-ox', (req, res) => {
|
app.get('/api/cam/pulse-ox', (req, res) => {
|
||||||
const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, {
|
const haReq = http.get(`${HA_URL}/api/camera_proxy/camera.pulse_ox_live_view`, {
|
||||||
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
||||||
|
|
@ -251,7 +386,6 @@ app.get('/api/cam/pulse-ox', (req, res) => {
|
||||||
haReq.setTimeout(5000, () => { haReq.destroy(); res.status(504).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) => {
|
app.get('/api/cam/pulse-ox/stream', (req, res) => {
|
||||||
const haReq = http.get(`${HA_URL}/api/camera_proxy_stream/camera.pulse_ox_live_view`, {
|
const haReq = http.get(`${HA_URL}/api/camera_proxy_stream/camera.pulse_ox_live_view`, {
|
||||||
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
headers: { 'Authorization': `Bearer ${HA_TOKEN}` }
|
||||||
|
|
@ -264,7 +398,7 @@ app.get('/api/cam/pulse-ox/stream', (req, res) => {
|
||||||
haReq.on('error', () => res.status(502).end());
|
haReq.on('error', () => res.status(502).end());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle done
|
// ─── Toggle done ──────────────────────────────────────────────────────────────
|
||||||
app.patch('/api/alerts/:id/done', (req, res) => {
|
app.patch('/api/alerts/:id/done', (req, res) => {
|
||||||
const alerts = loadAlerts();
|
const alerts = loadAlerts();
|
||||||
const alert = alerts.find(a => a.id === req.params.id);
|
const alert = alerts.find(a => a.id === req.params.id);
|
||||||
|
|
@ -274,21 +408,20 @@ app.patch('/api/alerts/:id/done', (req, res) => {
|
||||||
res.json(alert);
|
res.json(alert);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete alert
|
// ─── Delete alert ─────────────────────────────────────────────────────────────
|
||||||
app.delete('/api/alerts/:id', (req, res) => {
|
app.delete('/api/alerts/:id', (req, res) => {
|
||||||
const alerts = loadAlerts();
|
const alerts = loadAlerts();
|
||||||
const idx = alerts.findIndex(a => a.id === req.params.id);
|
const idx = alerts.findIndex(a => a.id === req.params.id);
|
||||||
if (idx === -1) return res.status(404).json({ error: 'not found' });
|
if (idx === -1) return res.status(404).json({ error: 'not found' });
|
||||||
alerts.splice(idx, 1);
|
alerts.splice(idx, 1);
|
||||||
saveAlerts(alerts);
|
saveAlerts(alerts);
|
||||||
// Notify SSE clients of removal
|
|
||||||
for (const client of sseClients) {
|
for (const client of sseClients) {
|
||||||
client.write(`data: ${JSON.stringify({ type: 'remove', id: req.params.id })}\n\n`);
|
client.write(`data: ${JSON.stringify({ type: 'remove', id: req.params.id })}\n\n`);
|
||||||
}
|
}
|
||||||
res.json({ status: 'deleted' });
|
res.json({ status: 'deleted' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// SSE stream
|
// ─── SSE stream ───────────────────────────────────────────────────────────────
|
||||||
app.get('/api/alerts/stream', (req, res) => {
|
app.get('/api/alerts/stream', (req, res) => {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream',
|
'Content-Type': 'text/event-stream',
|
||||||
|
|
@ -300,7 +433,7 @@ app.get('/api/alerts/stream', (req, res) => {
|
||||||
req.on('close', () => sseClients.delete(res));
|
req.on('close', () => sseClients.delete(res));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-purge done items older than 2 hours
|
// ─── Auto-purge done items older than 2 hours ─────────────────────────────────
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const alerts = loadAlerts();
|
const alerts = loadAlerts();
|
||||||
const cutoff = Date.now() - 2 * 3600000;
|
const cutoff = Date.now() - 2 * 3600000;
|
||||||
|
|
@ -311,12 +444,9 @@ setInterval(() => {
|
||||||
client.write(`data: ${JSON.stringify({ type: 'refresh' })}\n\n`);
|
client.write(`data: ${JSON.stringify({ type: 'refresh' })}\n\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 300000); // Check every 5 min
|
}, 300_000);
|
||||||
|
|
||||||
// Meeting storage
|
// ─── Claude usage endpoint ────────────────────────────────────────────────────
|
||||||
let nextMeeting = null;
|
|
||||||
|
|
||||||
// Claude usage endpoint
|
|
||||||
app.get('/api/claude-usage', async (req, res) => {
|
app.get('/api/claude-usage', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const jamesData = await new Promise((resolve, reject) => {
|
const jamesData = await new Promise((resolve, reject) => {
|
||||||
|
|
@ -328,72 +458,120 @@ app.get('/api/claude-usage', async (req, res) => {
|
||||||
r.on('error', reject);
|
r.on('error', reject);
|
||||||
r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
|
r.setTimeout(3000, () => { r.destroy(); reject(new Error('timeout')); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// James dashboard returns {status: {key: {value: "..."}, ...}}
|
const statuses = jamesData.status || jamesData;
|
||||||
const statuses = jamesData.status || jamesData;
|
const usage = statuses['claude-usage'] || statuses['claude'];
|
||||||
const usage = statuses['claude-usage'] || statuses['claude'];
|
let usagePct = null;
|
||||||
|
|
||||||
// Extract usage percentage from value like "📊 Weekly: 46% used" or "46% used · 2:00 PM"
|
|
||||||
let usagePct = null;
|
|
||||||
if (usage && usage.value) {
|
if (usage && usage.value) {
|
||||||
const m = usage.value.match(/(\d+)%/);
|
const m = usage.value.match(/(\d+)%/);
|
||||||
if (m) usagePct = parseInt(m[1]);
|
if (m) usagePct = parseInt(m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time% into budget week (Sat 2pm - Sat 2pm ET)
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Find most recent Saturday 2pm ET
|
const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
||||||
const et = new Date(now.toLocaleString('en-US', { timeZone: 'America/New_York' }));
|
const day = et.getDay();
|
||||||
const day = et.getDay(); // 0=Sun
|
|
||||||
// Days since last Saturday
|
|
||||||
const daysSinceSat = day === 6 ? 0 : day + 1;
|
const daysSinceSat = day === 6 ? 0 : day + 1;
|
||||||
const lastSat = new Date(et);
|
const lastSat = new Date(et);
|
||||||
lastSat.setDate(lastSat.getDate() - daysSinceSat);
|
lastSat.setDate(lastSat.getDate() - daysSinceSat);
|
||||||
lastSat.setHours(14, 0, 0, 0); // 2pm
|
lastSat.setHours(14, 0, 0, 0);
|
||||||
// If we're before 2pm on Saturday, go back one more week
|
if (et < lastSat) lastSat.setDate(lastSat.getDate() - 7);
|
||||||
if (et < lastSat) {
|
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
lastSat.setDate(lastSat.getDate() - 7);
|
|
||||||
}
|
|
||||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const elapsed = et - lastSat;
|
const elapsed = et - lastSat;
|
||||||
const timePct = Math.min(100, Math.max(0, (elapsed / weekMs) * 100));
|
const timePct = Math.min(100, Math.max(0, (elapsed / weekMs) * 100));
|
||||||
|
const pace = timePct > 0 ? Math.round((usagePct / timePct) * 100) : null;
|
||||||
// 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 });
|
res.json({ usage: usagePct, timePct: Math.round(timePct), pace });
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
res.status(502).json({ error: 'james dashboard unavailable' });
|
res.status(502).json({ error: 'james dashboard unavailable' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meeting endpoints
|
// ─── 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) => {
|
app.get('/api/meeting', (req, res) => {
|
||||||
if (!nextMeeting) return res.json(null);
|
if (!nextMeeting) return res.json(null);
|
||||||
|
if (new Date(nextMeeting.time) < new Date()) {
|
||||||
const now = new Date();
|
|
||||||
const meetingTime = new Date(nextMeeting.time);
|
|
||||||
|
|
||||||
// Remove if in the past
|
|
||||||
if (meetingTime < now) {
|
|
||||||
nextMeeting = null;
|
nextMeeting = null;
|
||||||
return res.json(null);
|
return res.json(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(nextMeeting);
|
res.json(nextMeeting);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Set next meeting (with voice announcement scheduling) ───────────────────
|
||||||
app.post('/api/meeting', (req, res) => {
|
app.post('/api/meeting', (req, res) => {
|
||||||
const { title, time, id } = req.body;
|
const { title, time, id, organizer, topic } = req.body;
|
||||||
if (!title || !time) return res.status(400).json({ error: 'title and time required' });
|
if (!title || !time) return res.status(400).json({ error: 'title and time required' });
|
||||||
|
|
||||||
nextMeeting = { title, time, id: id || Date.now().toString(36) };
|
// 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);
|
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', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Alert dashboard running on http://0.0.0.0:${PORT}`);
|
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)}…`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue