feat: Pulse sensor integration with real-time WebSocket alerts
This commit is contained in:
parent
2ca6fb01f4
commit
5c86b98628
12 changed files with 997 additions and 9 deletions
5
.agent/rules/dev.md
Normal file
5
.agent/rules/dev.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
workspace is ~/DEV
|
||||
117
backend/package-lock.json
generated
117
backend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/jwt": "^7.2.4",
|
||||
"@fastify/multipart": "^8.0.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
|
@ -799,6 +800,43 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"duplexify": "^4.1.3",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/websocket/node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
|
@ -2896,6 +2934,18 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexify": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.4.1",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1",
|
||||
"stream-shift": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dynamic-dedupe": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz",
|
||||
|
|
@ -2942,6 +2992,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
|
|
@ -5026,7 +5085,6 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
|
|
@ -5481,6 +5539,20 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
|
|
@ -5903,6 +5975,12 @@
|
|||
"reusify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stream-shift": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stream-wormhole": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz",
|
||||
|
|
@ -5912,6 +5990,15 @@
|
|||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-length": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||
|
|
@ -6393,6 +6480,12 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
|
|
@ -6480,7 +6573,6 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
|
|
@ -6497,6 +6589,27 @@
|
|||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/jwt": "^7.2.4",
|
||||
"@fastify/multipart": "^8.0.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
|
@ -40,4 +41,4 @@
|
|||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
backend/src/plugins/websocket.ts
Normal file
115
backend/src/plugins/websocket.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* WebSocket Plugin for Real-time Alerts
|
||||
*
|
||||
* Broadcasts environment alerts to connected clients.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import websocket from '@fastify/websocket';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
interface AlertMessage {
|
||||
type: 'ALERT' | 'READING' | 'HEARTBEAT';
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Connected clients
|
||||
const clients: Map<string, WebSocket> = new Map();
|
||||
|
||||
export async function websocketPlugin(fastify: FastifyInstance) {
|
||||
await fastify.register(websocket);
|
||||
|
||||
/**
|
||||
* WebSocket endpoint for real-time alerts
|
||||
*/
|
||||
fastify.get('/ws/alerts', { websocket: true }, (socket, request) => {
|
||||
const clientId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
clients.set(clientId, socket);
|
||||
fastify.log.info(`WebSocket client connected: ${clientId}`);
|
||||
|
||||
// Send welcome message
|
||||
socket.send(JSON.stringify({
|
||||
type: 'CONNECTED',
|
||||
clientId,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
socket.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
// Handle ping/pong for keepalive
|
||||
if (data.type === 'PING') {
|
||||
socket.send(JSON.stringify({ type: 'PONG', timestamp: new Date().toISOString() }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid messages
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clients.delete(clientId);
|
||||
fastify.log.info(`WebSocket client disconnected: ${clientId}`);
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
fastify.log.error(`WebSocket error for ${clientId}:`, error);
|
||||
clients.delete(clientId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an alert to all connected clients
|
||||
*/
|
||||
export function broadcastAlert(alert: any): void {
|
||||
const message: AlertMessage = {
|
||||
type: 'ALERT',
|
||||
data: alert,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
clients.forEach((socket, clientId) => {
|
||||
try {
|
||||
if (socket.readyState === 1) { // OPEN
|
||||
socket.send(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to broadcast to ${clientId}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a sensor reading update
|
||||
*/
|
||||
export function broadcastReading(reading: any): void {
|
||||
const message: AlertMessage = {
|
||||
type: 'READING',
|
||||
data: reading,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
clients.forEach((socket, clientId) => {
|
||||
try {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to broadcast reading to ${clientId}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of connected clients
|
||||
*/
|
||||
export function getConnectedClientCount(): number {
|
||||
return clients.size;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { broadcastAlert } from '../plugins/websocket';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
|
@ -586,8 +587,16 @@ export async function environmentRoutes(fastify: FastifyInstance) {
|
|||
...data
|
||||
});
|
||||
|
||||
// TODO: Fan out to notification channels (in-app notifications)
|
||||
// This will integrate with the notification service for mobile push
|
||||
// Broadcast to WebSocket clients for real-time notifications
|
||||
broadcastAlert({
|
||||
id: alert.id,
|
||||
type: data.alertType,
|
||||
sensorName: data.sensorName,
|
||||
value: data.currentValue,
|
||||
threshold: data.threshold,
|
||||
message: alert.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
178
backend/src/routes/pulse.routes.ts
Normal file
178
backend/src/routes/pulse.routes.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Pulse Integration Routes
|
||||
*
|
||||
* Exposes Pulse sensor data through Veridian's API.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { getPulseService, initPulseService } from '../services/pulse.service';
|
||||
|
||||
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();
|
||||
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/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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
124
backend/src/scripts/demo-alerts.ts
Normal file
124
backend/src/scripts/demo-alerts.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Demo Alert Generator
|
||||
*
|
||||
* Generates fake sensor alerts for demonstration purposes.
|
||||
* Run with: npx ts-node src/scripts/demo-alerts.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEMO_SENSORS = [
|
||||
{ id: 'demo-pulse-1', name: 'Flower Room - Pulse Pro', roomId: 'flower-room' },
|
||||
{ id: 'demo-pulse-2', name: 'Veg Room - Pulse', roomId: 'veg-room' },
|
||||
];
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ type: 'TEMPERATURE_HIGH', metric: 'temperature', min: 82, max: 88 },
|
||||
{ type: 'TEMPERATURE_LOW', metric: 'temperature', min: 58, max: 64 },
|
||||
{ type: 'HUMIDITY_HIGH', metric: 'humidity', min: 72, max: 78 },
|
||||
{ type: 'HUMIDITY_LOW', metric: 'humidity', min: 35, max: 42 },
|
||||
{ type: 'VPD_HIGH', metric: 'vpd', min: 1.5, max: 1.8 },
|
||||
];
|
||||
|
||||
async function generateDemoAlert() {
|
||||
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||
const alertConfig = ALERT_TYPES[Math.floor(Math.random() * ALERT_TYPES.length)];
|
||||
|
||||
const value = alertConfig.min + Math.random() * (alertConfig.max - alertConfig.min);
|
||||
const threshold = alertConfig.type.includes('HIGH')
|
||||
? value - 5
|
||||
: value + 5;
|
||||
|
||||
const alert = await prisma.environmentAlert.create({
|
||||
data: {
|
||||
type: alertConfig.type,
|
||||
severity: 'WARNING',
|
||||
message: `${sensor.name}: ${alertConfig.metric} ${alertConfig.type.includes('HIGH') ? 'above' : 'below'} threshold (${value.toFixed(1)} vs ${threshold.toFixed(1)})`,
|
||||
value: value,
|
||||
threshold: threshold,
|
||||
metadata: {
|
||||
sensorId: sensor.id,
|
||||
sensorName: sensor.name,
|
||||
roomId: sensor.roomId,
|
||||
isDemo: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🚨 Generated alert: ${alert.message}`);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async function generateDemoReading() {
|
||||
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||
|
||||
// Generate realistic readings
|
||||
const temperature = 72 + (Math.random() - 0.5) * 10; // 67-77°F
|
||||
const humidity = 55 + (Math.random() - 0.5) * 15; // 47-62%
|
||||
const vpd = 1.0 + (Math.random() - 0.5) * 0.4; // 0.8-1.2 kPa
|
||||
|
||||
const reading = await prisma.sensorReading.create({
|
||||
data: {
|
||||
sensorId: sensor.id,
|
||||
value: temperature,
|
||||
unit: '°F',
|
||||
timestamp: new Date(),
|
||||
metadata: {
|
||||
humidity,
|
||||
vpd,
|
||||
sensorName: sensor.name,
|
||||
isDemo: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📊 Generated reading: ${sensor.name} - ${temperature.toFixed(1)}°F, ${humidity.toFixed(1)}% RH`);
|
||||
return reading;
|
||||
}
|
||||
|
||||
async function runDemoMode(intervalMs: number = 5000, alertChance: number = 0.2) {
|
||||
console.log('🎭 Demo mode started');
|
||||
console.log(` Interval: ${intervalMs}ms`);
|
||||
console.log(` Alert chance: ${alertChance * 100}%`);
|
||||
console.log(' Press Ctrl+C to stop\n');
|
||||
|
||||
const loop = async () => {
|
||||
await generateDemoReading();
|
||||
|
||||
if (Math.random() < alertChance) {
|
||||
await generateDemoAlert();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial run
|
||||
await loop();
|
||||
|
||||
// Repeat
|
||||
setInterval(loop, intervalMs);
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0] || 'run';
|
||||
|
||||
if (command === 'alert') {
|
||||
generateDemoAlert().then(() => process.exit(0));
|
||||
} else if (command === 'reading') {
|
||||
generateDemoReading().then(() => process.exit(0));
|
||||
} else if (command === 'run') {
|
||||
const interval = parseInt(args[1] || '5000');
|
||||
const alertChance = parseFloat(args[2] || '0.2');
|
||||
runDemoMode(interval, alertChance);
|
||||
} else {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx ts-node src/scripts/demo-alerts.ts alert - Generate single alert
|
||||
npx ts-node src/scripts/demo-alerts.ts reading - Generate single reading
|
||||
npx ts-node src/scripts/demo-alerts.ts run [interval] [alertChance]
|
||||
- Run continuous demo mode
|
||||
- interval: ms between readings (default: 5000)
|
||||
- alertChance: 0-1 probability (default: 0.2)
|
||||
`);
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ import { metrcRoutes } from './routes/metrc.routes';
|
|||
import { visitorRoutes } from './routes/visitors.routes';
|
||||
import { accessZoneRoutes } from './routes/access-zones.routes';
|
||||
import { messagingRoutes } from './routes/messaging.routes';
|
||||
import { pulseRoutes } from './routes/pulse.routes';
|
||||
import { websocketPlugin } from './plugins/websocket';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
|
|
@ -77,6 +79,12 @@ server.register(financialRoutes, { prefix: '/api/financial' });
|
|||
server.register(insightsRoutes, { prefix: '/api/insights' });
|
||||
server.register(uploadRoutes, { prefix: '/api/upload' });
|
||||
|
||||
// Pulse sensor integration
|
||||
server.register(pulseRoutes, { prefix: '/api/pulse' });
|
||||
|
||||
// WebSocket for real-time alerts
|
||||
server.register(websocketPlugin);
|
||||
|
||||
// Admin routes (demo/testing)
|
||||
import { adminRoutes } from './routes/admin.routes';
|
||||
server.register(adminRoutes, { prefix: '/api/admin' });
|
||||
|
|
|
|||
157
backend/src/services/pulse.service.ts
Normal file
157
backend/src/services/pulse.service.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* Pulse Grow API Service
|
||||
*
|
||||
* Server-side integration with Pulse Grow sensor platform.
|
||||
* API Docs: https://api.pulsegrow.com/docs
|
||||
*/
|
||||
|
||||
const PULSE_API_BASE = 'https://api.pulsegrow.com';
|
||||
|
||||
export interface PulseDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export interface PulseReading {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
temperature: number; // Fahrenheit
|
||||
humidity: number; // %
|
||||
vpd: number; // kPa
|
||||
dewpoint: number; // Fahrenheit
|
||||
light?: number; // PPFD (for Pro devices)
|
||||
co2?: number; // ppm (for Pro devices)
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class PulseService {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
private async fetch(path: string): Promise<any> {
|
||||
const res = await fetch(`${PULSE_API_BASE}${path}`, {
|
||||
headers: {
|
||||
'x-api-key': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Pulse API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices for this grow
|
||||
*/
|
||||
async getDevices(): Promise<PulseDevice[]> {
|
||||
const data = await this.fetch('/devices');
|
||||
return (data.devices || data || []).map((d: any) => ({
|
||||
id: d.id || d.deviceId,
|
||||
name: d.name || d.deviceName || 'Unknown',
|
||||
type: d.type || 'pulse',
|
||||
isOnline: d.isOnline ?? d.online ?? true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current readings for all devices
|
||||
*/
|
||||
async getCurrentReadings(): Promise<PulseReading[]> {
|
||||
const devices = await this.getDevices();
|
||||
const readings: PulseReading[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
try {
|
||||
const reading = await this.getDeviceReading(device.id);
|
||||
if (reading) {
|
||||
readings.push({
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
...reading,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get reading for ${device.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current reading for a specific device
|
||||
*/
|
||||
async getDeviceReading(deviceId: string): Promise<Omit<PulseReading, 'deviceId' | 'deviceName'> | null> {
|
||||
try {
|
||||
const data = await this.fetch(`/devices/${deviceId}/sensors/current`);
|
||||
|
||||
return {
|
||||
temperature: data.temperature?.value ?? data.temp ?? 0,
|
||||
humidity: data.humidity?.value ?? data.rh ?? 0,
|
||||
vpd: data.vpd?.value ?? data.vpd ?? 0,
|
||||
dewpoint: data.dewpoint?.value ?? data.dew ?? 0,
|
||||
light: data.ppfd?.value ?? data.light,
|
||||
co2: data.co2?.value ?? data.co2,
|
||||
timestamp: new Date(data.timestamp || Date.now()),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical data for a device
|
||||
*/
|
||||
async getHistory(deviceId: string, hours: number = 24): Promise<PulseReading[]> {
|
||||
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
const data = await this.fetch(`/devices/${deviceId}/sensors/data?start=${start}`);
|
||||
|
||||
return (data.readings || data || []).map((r: any) => ({
|
||||
deviceId,
|
||||
deviceName: '',
|
||||
temperature: r.temperature ?? r.temp ?? 0,
|
||||
humidity: r.humidity ?? r.rh ?? 0,
|
||||
vpd: r.vpd ?? 0,
|
||||
dewpoint: r.dewpoint ?? r.dew ?? 0,
|
||||
light: r.ppfd ?? r.light,
|
||||
co2: r.co2,
|
||||
timestamp: new Date(r.timestamp),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Pulse API
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; deviceCount: number; error?: string }> {
|
||||
try {
|
||||
const devices = await this.getDevices();
|
||||
return { success: true, deviceCount: devices.length };
|
||||
} catch (error: any) {
|
||||
return { success: false, deviceCount: 0, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance (initialized with API key from env)
|
||||
let pulseServiceInstance: PulseService | null = null;
|
||||
|
||||
export function getPulseService(): PulseService | null {
|
||||
if (!pulseServiceInstance && process.env.PULSE_API_KEY) {
|
||||
pulseServiceInstance = new PulseService(process.env.PULSE_API_KEY);
|
||||
}
|
||||
return pulseServiceInstance;
|
||||
}
|
||||
|
||||
export function initPulseService(apiKey: string): PulseService {
|
||||
pulseServiceInstance = new PulseService(apiKey);
|
||||
return pulseServiceInstance;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { pageVariants } from '../lib/animations';
|
|||
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { UserMenu } from './layout/UserMenu';
|
||||
import { NotificationBell } from './notifications/NotificationBell';
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
|
|
@ -91,10 +92,7 @@ export default function Layout() {
|
|||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
<button className="relative p-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors rounded-lg hover:bg-[var(--color-bg-tertiary)]">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-[var(--color-error)] rounded-full ring-2 ring-[var(--color-bg-primary)]" />
|
||||
</button>
|
||||
<NotificationBell />
|
||||
<button className="lg:hidden p-2 text-[var(--color-text-tertiary)]" onClick={() => setMobileSheetOpen(true)}>
|
||||
<Filter size={20} />
|
||||
</button>
|
||||
|
|
|
|||
129
frontend/src/components/notifications/NotificationBell.tsx
Normal file
129
frontend/src/components/notifications/NotificationBell.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Notification Bell Component
|
||||
*
|
||||
* Shows in the header with badge count and dropdown of recent alerts.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, X, AlertTriangle, Thermometer, Droplets } from 'lucide-react';
|
||||
import { useNotifications } from '../../hooks/useNotifications';
|
||||
|
||||
export function NotificationBell() {
|
||||
const { connected, alerts, unreadCount, clearUnread, clearAll } = useNotifications();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleOpen = () => {
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen && unreadCount > 0) {
|
||||
clearUnread();
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertIcon = (type: string) => {
|
||||
if (type.includes('TEMPERATURE')) return <Thermometer className="w-4 h-4" />;
|
||||
if (type.includes('HUMIDITY')) return <Droplets className="w-4 h-4" />;
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
if (type.includes('HIGH')) return 'text-red-500';
|
||||
if (type.includes('LOW')) return 'text-blue-500';
|
||||
return 'text-yellow-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Bell Button */}
|
||||
<button
|
||||
onClick={toggleOpen}
|
||||
className="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className={`w-5 h-5 ${connected ? 'text-gray-600 dark:text-gray-400' : 'text-gray-400'}`} />
|
||||
|
||||
{/* Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full animate-pulse">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Connection indicator */}
|
||||
<span className={`absolute bottom-1 right-1 w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="absolute right-0 top-12 z-50 w-80 max-h-96 overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
{alerts.length > 0 && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{alerts.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">
|
||||
<Bell className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
alerts.map((alert, index) => (
|
||||
<div
|
||||
key={alert.id || index}
|
||||
className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${getAlertColor(alert.type)}`}>
|
||||
{getAlertIcon(alert.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900 dark:text-white truncate">
|
||||
{alert.sensorName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{new Date(alert.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<a
|
||||
href="/environment"
|
||||
className="block text-center text-sm text-emerald-600 hover:text-emerald-700 dark:text-emerald-500"
|
||||
>
|
||||
View all alerts →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
frontend/src/hooks/useNotifications.ts
Normal file
151
frontend/src/hooks/useNotifications.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* WebSocket hook for real-time alerts
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AlertMessage {
|
||||
id: string;
|
||||
type: string;
|
||||
sensorName: string;
|
||||
value: number;
|
||||
threshold: number;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface UseNotificationsOptions {
|
||||
showToasts?: boolean;
|
||||
playSound?: boolean;
|
||||
}
|
||||
|
||||
export function useNotifications(options: UseNotificationsOptions = {}) {
|
||||
const { showToasts = true, playSound = true } = options;
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [alerts, setAlerts] = useState<AlertMessage[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// Determine WebSocket URL based on current location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/ws/alerts`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('🔔 Notification WebSocket connected');
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'ALERT') {
|
||||
const alert = message.data as AlertMessage;
|
||||
|
||||
// Add to alerts list
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 50));
|
||||
setUnreadCount(prev => prev + 1);
|
||||
|
||||
// Show toast notification
|
||||
if (showToasts) {
|
||||
const isHigh = alert.type.includes('HIGH');
|
||||
toast.error(alert.message, {
|
||||
icon: isHigh ? '🔥' : '❄️',
|
||||
duration: 10000,
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => {
|
||||
// Could navigate to environment dashboard
|
||||
window.location.href = '/environment';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Play sound
|
||||
if (playSound) {
|
||||
playAlertSound();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse WebSocket message:', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('🔔 Notification WebSocket disconnected');
|
||||
setConnected(false);
|
||||
|
||||
// Reconnect after delay
|
||||
setTimeout(connect, 5000);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.close();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to connect WebSocket:', error);
|
||||
}
|
||||
}, [showToasts, playSound]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearUnread = useCallback(() => {
|
||||
setUnreadCount(0);
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setAlerts([]);
|
||||
setUnreadCount(0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => disconnect();
|
||||
}, [connect, disconnect]);
|
||||
|
||||
return {
|
||||
connected,
|
||||
alerts,
|
||||
unreadCount,
|
||||
clearUnread,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
|
||||
function playAlertSound() {
|
||||
try {
|
||||
// Create a simple beep sound
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
|
||||
const ctx = new AudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
} catch {
|
||||
// Ignore audio errors
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue