/** * Pulse Integration Routes * * Exposes Pulse sensor data through Veridian's API. */ import { FastifyInstance } from 'fastify'; import { PrismaClient } from '@prisma/client'; import { getPulseService, initPulseService } from '../services/pulse.service'; const prisma = new PrismaClient(); export async function pulseRoutes(fastify: FastifyInstance) { // Auth middleware fastify.addHook('onRequest', async (request) => { try { await request.jwtVerify(); } catch (err) { throw err; } }); /** * GET /pulse/status * Check Pulse API connection status */ fastify.get('/status', { handler: async (request, reply) => { const pulse = getPulseService(); if (!pulse) { return { connected: false, error: 'Pulse API key not configured', hint: 'Set PULSE_API_KEY environment variable' }; } const result = await pulse.testConnection(); return { connected: result.success, deviceCount: result.deviceCount, error: result.error }; } }); /** * POST /pulse/configure * Configure Pulse API key (admin only) */ fastify.post('/configure', { handler: async (request, reply) => { const { apiKey } = request.body as { apiKey: string }; if (!apiKey) { return reply.status(400).send({ error: 'API key required' }); } // Test the key before saving const testService = initPulseService(apiKey); const result = await testService.testConnection(); if (!result.success) { return reply.status(400).send({ error: 'Invalid API key', details: result.error }); } // TODO: Store in database/env for persistence return { success: true, deviceCount: result.deviceCount, message: 'Pulse API configured successfully' }; } }); /** * GET /pulse/devices * List all Pulse devices */ fastify.get('/devices', { handler: async (request, reply) => { const pulse = getPulseService(); if (!pulse) { return reply.status(503).send({ error: 'Pulse not configured' }); } try { const devices = await pulse.getDevices(); return { devices }; } catch (error: any) { fastify.log.error(error); return reply.status(500).send({ error: error.message }); } } }); /** * GET /pulse/readings * Get current readings from all devices */ fastify.get('/readings', { handler: async (request, reply) => { const pulse = getPulseService(); if (!pulse) { return reply.status(503).send({ error: 'Pulse not configured' }); } try { const readings = await pulse.getCurrentReadings(); // Check thresholds and persist alerts (for demo/failsafe) const alerts: any[] = []; for (const reading of readings) { const deviceIdentifier = reading.deviceName || reading.deviceId; if (reading.temperature !== undefined && pulseThresholds.temperature.max && reading.temperature > pulseThresholds.temperature.max) { const alertType = 'TEMPERATURE_HIGH'; // Check if active alert exists const activeAlert = await prisma.environmentAlert.findFirst({ where: { type: alertType, message: { contains: deviceIdentifier }, resolvedAt: null } }); if (!activeAlert) { const newAlert = await prisma.environmentAlert.create({ data: { type: alertType, severity: 'WARNING', message: `${deviceIdentifier}: ${alertType.replace('_', ' ')} (${reading.temperature.toFixed(1)}°F > ${pulseThresholds.temperature.max}°F)`, value: reading.temperature, threshold: pulseThresholds.temperature.max, createdAt: new Date() } }); alerts.push(createAlert(reading, alertType, reading.temperature, pulseThresholds.temperature.max)); fastify.log.info(`🚨 Created new Pulse alert: ${newAlert.id}`); } } else if (reading.temperature !== undefined && pulseThresholds.temperature.max) { // Temperature is within threshold - AUTO-RESOLVE any active alerts for this device const resolvedAlerts = await prisma.environmentAlert.updateMany({ where: { type: 'TEMPERATURE_HIGH', message: { contains: deviceIdentifier }, resolvedAt: null }, data: { resolvedAt: new Date() } }); if (resolvedAlerts.count > 0) { fastify.log.info(`✅ Auto-resolved ${resolvedAlerts.count} alerts for ${deviceIdentifier} (temp now ${reading.temperature.toFixed(1)}°F)`); } } } // Broadcast any new alerts if (alerts.length > 0) { alerts.forEach(a => broadcastAlert(a)); } return { readings, timestamp: new Date().toISOString() }; } catch (error: any) { fastify.log.error(error); return reply.status(500).send({ error: error.message }); } } }); /** * GET /pulse/devices/:id/readings * Get current reading for a specific device */ fastify.get('/devices/:id/readings', { handler: async (request, reply) => { const { id } = request.params as { id: string }; const pulse = getPulseService(); if (!pulse) { return reply.status(503).send({ error: 'Pulse not configured' }); } try { const reading = await pulse.getDeviceReading(id); if (!reading) { return reply.status(404).send({ error: 'Device not found' }); } return { deviceId: id, ...reading }; } catch (error: any) { fastify.log.error(error); return reply.status(500).send({ error: error.message }); } } }); /** * GET /pulse/sparklines * Get sparkline data for all devices */ fastify.get('/sparklines', { handler: async (request, reply) => { const pulse = getPulseService(); if (!pulse) { return reply.status(503).send({ error: 'Pulse not configured' }); } try { const sparklines = await pulse.getSparklines(); return { sparklines }; } catch (error: any) { fastify.log.error(error); return reply.status(500).send({ error: error.message }); } } }); /** * GET /pulse/devices/:id/history * Get historical readings for a device */ fastify.get('/devices/:id/history', { handler: async (request, reply) => { const { id } = request.params as { id: string }; const { hours = 24 } = request.query as { hours?: number }; const pulse = getPulseService(); if (!pulse) { return reply.status(503).send({ error: 'Pulse not configured' }); } try { const readings = await pulse.getHistory(id, hours); return { deviceId: id, hours, count: readings.length, readings }; } catch (error: any) { fastify.log.error(error); return reply.status(500).send({ error: error.message }); } } }); /** * GET /pulse/thresholds * Get current threshold configuration */ fastify.get('/thresholds', { handler: async (request, reply) => { return { thresholds: pulseThresholds, lastUpdated: thresholdsLastUpdated?.toISOString() || null }; } }); /** * POST /pulse/thresholds * Set threshold alerts for Pulse sensors */ fastify.post('/thresholds', { handler: async (request, reply) => { const config = request.body as { temperature?: { min: number; max: number }; humidity?: { min: number; max: number }; vpd?: { min: number; max: number }; co2?: { min: number; max: number }; }; if (config.temperature) { pulseThresholds.temperature = config.temperature; } if (config.humidity) { pulseThresholds.humidity = config.humidity; } if (config.vpd) { pulseThresholds.vpd = config.vpd; } if (config.co2) { pulseThresholds.co2 = config.co2; } thresholdsLastUpdated = new Date(); fastify.log.info({ event: 'pulse_thresholds_updated', thresholds: pulseThresholds }); return { success: true, thresholds: pulseThresholds, message: 'Thresholds updated successfully' }; } }); /** * POST /pulse/check * Check current readings against thresholds and broadcast alerts */ fastify.post('/check', { handler: async (request, reply) => { const pulse = getPulseService(); if (!pulse) { return reply.status(503).send({ error: 'Pulse not configured' }); } try { const readings = await pulse.getCurrentReadings(); const alerts: any[] = []; for (const reading of readings) { // Temperature check if (reading.temperature !== undefined) { if (pulseThresholds.temperature.max && reading.temperature > pulseThresholds.temperature.max) { alerts.push(createAlert(reading, 'TEMPERATURE_HIGH', reading.temperature, pulseThresholds.temperature.max)); } if (pulseThresholds.temperature.min && reading.temperature < pulseThresholds.temperature.min) { alerts.push(createAlert(reading, 'TEMPERATURE_LOW', reading.temperature, pulseThresholds.temperature.min)); } } // Humidity check if (reading.humidity !== undefined) { if (pulseThresholds.humidity.max && reading.humidity > pulseThresholds.humidity.max) { alerts.push(createAlert(reading, 'HUMIDITY_HIGH', reading.humidity, pulseThresholds.humidity.max)); } if (pulseThresholds.humidity.min && reading.humidity < pulseThresholds.humidity.min) { alerts.push(createAlert(reading, 'HUMIDITY_LOW', reading.humidity, pulseThresholds.humidity.min)); } } // VPD check if (reading.vpd !== undefined) { if (pulseThresholds.vpd.max && reading.vpd > pulseThresholds.vpd.max) { alerts.push(createAlert(reading, 'VPD_HIGH', reading.vpd, pulseThresholds.vpd.max)); } if (pulseThresholds.vpd.min && reading.vpd < pulseThresholds.vpd.min) { alerts.push(createAlert(reading, 'VPD_LOW', reading.vpd, pulseThresholds.vpd.min)); } } // CO2 check if (reading.co2 !== undefined) { if (pulseThresholds.co2.max && reading.co2 > pulseThresholds.co2.max) { alerts.push(createAlert(reading, 'CO2_HIGH', reading.co2, pulseThresholds.co2.max)); } if (pulseThresholds.co2.min && reading.co2 < pulseThresholds.co2.min) { alerts.push(createAlert(reading, 'CO2_LOW', reading.co2, pulseThresholds.co2.min)); } } } // Broadcast alerts via WebSocket for (const alert of alerts) { broadcastAlert(alert); } return { devicesChecked: readings.length, alertsTriggered: alerts.length, alerts, timestamp: new Date().toISOString() }; } catch (error: any) { fastify.log.error(error); return reply.status(500).send({ error: error.message }); } } }); } // In-memory threshold storage (would be persisted to DB in production) const pulseThresholds = { temperature: { min: 65, max: 82 }, // °F humidity: { min: 40, max: 70 }, // % vpd: { min: 0.8, max: 1.2 }, // kPa co2: { min: 400, max: 1500 } // ppm }; let thresholdsLastUpdated: Date | null = null; // Import broadcast function import { broadcastAlert } from '../plugins/websocket'; function createAlert(reading: any, type: string, value: number, threshold: number) { return { id: `pulse-${Date.now()}-${Math.random().toString(36).slice(2)}`, type, sensorName: reading.deviceName || `Device ${reading.deviceId}`, value, threshold, message: `${reading.deviceName || 'Pulse Sensor'}: ${type.replace('_', ' ')} (${value.toFixed(1)} vs threshold ${threshold})`, timestamp: new Date().toISOString() }; }