feat: Pulse sensor integration with real-time WebSocket alerts
Some checks are pending
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run

This commit is contained in:
fullsizemalt 2026-01-05 20:09:39 -08:00
parent 2ca6fb01f4
commit 5c86b98628
12 changed files with 997 additions and 9 deletions

5
.agent/rules/dev.md Normal file
View file

@ -0,0 +1,5 @@
---
trigger: always_on
---
workspace is ~/DEV

View file

@ -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",

View file

@ -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",

View 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;
}

View file

@ -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,

View 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 });
}
}
});
}

View 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)
`);
}

View file

@ -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' });

View 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;
}

View file

@ -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>

View 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>
);
}

View 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
}
}