412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
/**
|
|
* 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()
|
|
};
|
|
}
|
|
|