diff --git a/.agent/rules/dev.md b/.agent/rules/dev.md new file mode 100644 index 0000000..d0cd8a3 --- /dev/null +++ b/.agent/rules/dev.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +--- + +workspace is ~/DEV diff --git a/backend/package-lock.json b/backend/package-lock.json index a9d2daf..b25efe0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 30cb213..6628cf7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/backend/src/plugins/websocket.ts b/backend/src/plugins/websocket.ts new file mode 100644 index 0000000..ba1c6b0 --- /dev/null +++ b/backend/src/plugins/websocket.ts @@ -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 = 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; +} diff --git a/backend/src/routes/environment.routes.ts b/backend/src/routes/environment.routes.ts index 0cab7e2..dbba15e 100644 --- a/backend/src/routes/environment.routes.ts +++ b/backend/src/routes/environment.routes.ts @@ -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, diff --git a/backend/src/routes/pulse.routes.ts b/backend/src/routes/pulse.routes.ts new file mode 100644 index 0000000..29b8470 --- /dev/null +++ b/backend/src/routes/pulse.routes.ts @@ -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 }); + } + } + }); +} diff --git a/backend/src/scripts/demo-alerts.ts b/backend/src/scripts/demo-alerts.ts new file mode 100644 index 0000000..7a4f05b --- /dev/null +++ b/backend/src/scripts/demo-alerts.ts @@ -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) +`); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 480b384..82d7657 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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' }); diff --git a/backend/src/services/pulse.service.ts b/backend/src/services/pulse.service.ts new file mode 100644 index 0000000..16012fa --- /dev/null +++ b/backend/src/services/pulse.service.ts @@ -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 { + 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 { + 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 { + 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 | 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 { + 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; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8d64c64..a4a192e 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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() { - + diff --git a/frontend/src/components/notifications/NotificationBell.tsx b/frontend/src/components/notifications/NotificationBell.tsx new file mode 100644 index 0000000..7bbe275 --- /dev/null +++ b/frontend/src/components/notifications/NotificationBell.tsx @@ -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 ; + if (type.includes('HUMIDITY')) return ; + return ; + }; + + 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 ( +
+ {/* Bell Button */} + + + {/* Dropdown */} + {isOpen && ( + <> + {/* Backdrop */} +
setIsOpen(false)} + /> + + {/* Panel */} +
+ {/* Header */} +
+

Notifications

+ {alerts.length > 0 && ( + + )} +
+ + {/* Alerts List */} +
+ {alerts.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( + alerts.map((alert, index) => ( +
+
+
+ {getAlertIcon(alert.type)} +
+
+

+ {alert.sensorName} +

+

+ {alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)}) +

+

+ {new Date(alert.timestamp).toLocaleTimeString()} +

+
+
+
+ )) + )} +
+ + {/* Footer */} + {alerts.length > 0 && ( + + )} +
+ + )} +
+ ); +} diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..df7b30d --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -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(null); + const [connected, setConnected] = useState(false); + const [alerts, setAlerts] = useState([]); + 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 + } +}