diff --git a/config.example.json b/config.example.json index 7e9cbcb..315a4d8 100644 --- a/config.example.json +++ b/config.example.json @@ -1,13 +1,44 @@ { "$schema": "https://veridian.runfoo.run/schemas/edge-config.json", - "version": "1.0", + "version": "2.0", "facilityId": "YOUR_FACILITY_UUID", - "backendUrl": "https://api.veridian.runfoo.run", - "backendApiKey": "YOUR_API_KEY", + "edgeId": "rpi-01", + "server": { + "url": "https://api.veridian.runfoo.run", + "apiKey": "YOUR_API_KEY", + "heartbeatIntervalSec": 60, + "syncIntervalSec": 300 + }, "sensorpush": { "email": "sensors@facility.com", "password": "YOUR_PASSWORD" }, + "alerts": { + "enabled": true, + "cooldownMinutes": 15, + "thresholds": [ + { + "sensor": "*", + "metric": "temperature", + "min": 60, + "max": 85 + }, + { + "sensor": "*", + "metric": "humidity", + "min": 40, + "max": 70 + } + ] + }, + "storage": { + "retentionDays": 30, + "maxRows": 100000 + }, + "dashboard": { + "enabled": true, + "port": 8080 + }, "sensorMappings": [ { "sensorId": "123456.7890", @@ -16,6 +47,5 @@ } ], "pollingIntervalSec": 60, - "bufferMaxRows": 10000, "logLevel": "info" } \ No newline at end of file diff --git a/src/alerts.ts b/src/alerts.ts new file mode 100644 index 0000000..7d79b7f --- /dev/null +++ b/src/alerts.ts @@ -0,0 +1,142 @@ +/** + * Alert Engine - Threshold monitoring with cooldowns + * + * Monitors sensor readings against configurable thresholds. + * Fires alerts locally and sends to server for notification fan-out. + */ + +export interface ThresholdConfig { + sensor: string; // Sensor ID or "*" for all + metric: 'temperature' | 'humidity' | 'vpd' | 'dewpoint'; + min?: number; + max?: number; +} + +export interface AlertConfig { + enabled: boolean; + cooldownMinutes: number; + thresholds: ThresholdConfig[]; +} + +export interface Alert { + id: string; + sensorId: string; + sensorName: string; + metric: string; + value: number; + threshold: number; + type: 'HIGH' | 'LOW'; + timestamp: Date; +} + +type AlertCallback = (alert: Alert) => void; + +export class AlertEngine { + private config: AlertConfig; + private cooldowns: Map = new Map(); + private onAlert: AlertCallback; + + constructor(config: AlertConfig, onAlert: AlertCallback) { + this.config = config; + this.onAlert = onAlert; + } + + /** + * Check a reading against thresholds + */ + checkReading(reading: { + sensorId: string; + sensorName: string; + temperature: number; + humidity: number; + vpd?: number; + dewpoint?: number; + }): Alert | null { + if (!this.config.enabled) return null; + + for (const threshold of this.config.thresholds) { + // Check if threshold applies to this sensor + if (threshold.sensor !== '*' && threshold.sensor !== reading.sensorId) { + continue; + } + + const value = reading[threshold.metric]; + if (value === undefined) continue; + + // Check high threshold + if (threshold.max !== undefined && value > threshold.max) { + return this.createAlert(reading, threshold.metric, value, threshold.max, 'HIGH'); + } + + // Check low threshold + if (threshold.min !== undefined && value < threshold.min) { + return this.createAlert(reading, threshold.metric, value, threshold.min, 'LOW'); + } + } + + return null; + } + + /** + * Create and emit an alert if not in cooldown + */ + private createAlert( + reading: { sensorId: string; sensorName: string }, + metric: string, + value: number, + threshold: number, + type: 'HIGH' | 'LOW' + ): Alert | null { + const cooldownKey = `${reading.sensorId}-${metric}-${type}`; + const lastAlert = this.cooldowns.get(cooldownKey); + + // Check cooldown + if (lastAlert) { + const cooldownMs = this.config.cooldownMinutes * 60 * 1000; + if (Date.now() - lastAlert.getTime() < cooldownMs) { + return null; // Still in cooldown + } + } + + // Create alert + const alert: Alert = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + sensorId: reading.sensorId, + sensorName: reading.sensorName, + metric, + value, + threshold, + type, + timestamp: new Date(), + }; + + // Set cooldown + this.cooldowns.set(cooldownKey, new Date()); + + // Emit alert + this.onAlert(alert); + + return alert; + } + + /** + * Clear a specific cooldown (for testing or manual reset) + */ + clearCooldown(sensorId: string, metric: string, type: 'HIGH' | 'LOW'): void { + this.cooldowns.delete(`${sensorId}-${metric}-${type}`); + } + + /** + * Clear all cooldowns + */ + clearAllCooldowns(): void { + this.cooldowns.clear(); + } + + /** + * Update thresholds at runtime + */ + updateThresholds(thresholds: ThresholdConfig[]): void { + this.config.thresholds = thresholds; + } +} diff --git a/src/config.ts b/src/config.ts index 0d876f2..a73aa0f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,9 +1,10 @@ /** - * Configuration loader for Veridian Edge Agent + * Configuration loader for Veridian Edge Agent v2.0 */ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import type { AlertConfig, ThresholdConfig } from './alerts'; export interface SensorMapping { sensorId: string; @@ -14,16 +15,35 @@ export interface SensorMapping { export interface EdgeConfig { version: string; facilityId: string; - backendUrl: string; - backendApiKey: string; + edgeId: string; + + server: { + url: string; + apiKey: string; + heartbeatIntervalSec: number; + syncIntervalSec: number; + }; + sensorpush: { email: string; password: string; gateway?: string; }; + + alerts: AlertConfig; + + storage: { + retentionDays: number; + maxRows: number; + }; + + dashboard: { + enabled: boolean; + port: number; + }; + sensorMappings: SensorMapping[]; pollingIntervalSec: number; - bufferMaxRows: number; logLevel: 'debug' | 'info' | 'warn' | 'error'; } @@ -56,19 +76,52 @@ export function loadConfig(): EdgeConfig { function parseConfig(path: string): EdgeConfig { console.log(`šŸ“ Loading config from: ${path}`); const content = readFileSync(path, 'utf-8'); - const config = JSON.parse(content) as EdgeConfig; + const raw = JSON.parse(content); + + // Handle both v1 and v2 config formats + const config: EdgeConfig = { + version: raw.version || '2.0', + facilityId: raw.facilityId, + edgeId: raw.edgeId || 'edge-01', + + server: raw.server || { + url: raw.backendUrl || '', + apiKey: raw.backendApiKey || '', + heartbeatIntervalSec: 60, + syncIntervalSec: 300, + }, + + sensorpush: raw.sensorpush, + + alerts: raw.alerts || { + enabled: true, + cooldownMinutes: 15, + thresholds: [ + { sensor: '*', metric: 'temperature', min: 60, max: 85 }, + { sensor: '*', metric: 'humidity', min: 40, max: 70 }, + ], + }, + + storage: raw.storage || { + retentionDays: 30, + maxRows: raw.bufferMaxRows || 100000, + }, + + dashboard: raw.dashboard || { + enabled: false, + port: 8080, + }, + + sensorMappings: raw.sensorMappings || [], + pollingIntervalSec: raw.pollingIntervalSec || 60, + logLevel: raw.logLevel || 'info', + }; // Validate required fields if (!config.facilityId) throw new Error('Config missing: facilityId'); - if (!config.backendUrl) throw new Error('Config missing: backendUrl'); + if (!config.server.url) throw new Error('Config missing: server.url'); if (!config.sensorpush?.email) throw new Error('Config missing: sensorpush.email'); if (!config.sensorpush?.password) throw new Error('Config missing: sensorpush.password'); - // Defaults - config.pollingIntervalSec = config.pollingIntervalSec || 60; - config.bufferMaxRows = config.bufferMaxRows || 10000; - config.logLevel = config.logLevel || 'info'; - config.sensorMappings = config.sensorMappings || []; - return config; } diff --git a/src/heartbeat.ts b/src/heartbeat.ts new file mode 100644 index 0000000..c1a379b --- /dev/null +++ b/src/heartbeat.ts @@ -0,0 +1,96 @@ +/** + * Heartbeat sender - Keep-alive signal to server + * + * Sends periodic heartbeats so the server knows the edge device is alive. + * Server can detect missed heartbeats and alert operators. + */ + +export interface HeartbeatData { + facilityId: string; + edgeId: string; + status: 'ok' | 'degraded' | 'error'; + sensorCount: number; + bufferSize: number; + lastReading?: string; // ISO timestamp + uptime: number; // seconds +} + +export class HeartbeatSender { + private backendUrl: string; + private apiKey: string; + private intervalId: ReturnType | null = null; + private startTime: Date; + private getData: () => Omit; + + constructor( + backendUrl: string, + apiKey: string, + getData: () => Omit + ) { + this.backendUrl = backendUrl.replace(/\/$/, ''); + this.apiKey = apiKey; + this.getData = getData; + this.startTime = new Date(); + } + + /** + * Start sending heartbeats at the specified interval + */ + start(intervalSec: number = 60): void { + if (this.intervalId) { + this.stop(); + } + + // Send initial heartbeat + this.send(); + + // Schedule periodic heartbeats + this.intervalId = setInterval(() => { + this.send(); + }, intervalSec * 1000); + + console.log(`šŸ’“ Heartbeat started (every ${intervalSec}s)`); + } + + /** + * Stop sending heartbeats + */ + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + /** + * Send a single heartbeat + */ + async send(): Promise { + try { + const data = this.getData(); + const heartbeat: HeartbeatData = { + ...data, + uptime: Math.floor((Date.now() - this.startTime.getTime()) / 1000), + }; + + const res = await fetch(`${this.backendUrl}/environment/heartbeat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + }, + body: JSON.stringify(heartbeat), + }); + + if (!res.ok) { + console.warn(`āš ļø Heartbeat failed: ${res.status}`); + return false; + } + + return true; + } catch (error) { + console.warn('āš ļø Heartbeat error:', error); + return false; + } + } +} diff --git a/src/index.ts b/src/index.ts index 4befa49..9488cfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,11 @@ /** - * Veridian Edge Agent - SensorPush Integration + * Veridian Edge Agent v2.0 - SensorPush Integration * * Main entry point for the edge device. - * Polls SensorPush Cloud API and syncs readings to Veridian backend. + * - Polls SensorPush Cloud API + * - Monitors thresholds and fires alerts + * - Syncs readings to Veridian backend + * - Sends heartbeats for monitoring */ import { SensorPushClient } from './sensorpush'; @@ -10,27 +13,51 @@ import { VeridianClient } from './veridian'; import { BufferManager } from './buffer'; import { startHealthServer } from './health'; import { loadConfig, type EdgeConfig } from './config'; +import { AlertEngine, type Alert } from './alerts'; +import { HeartbeatSender } from './heartbeat'; // Global state let config: EdgeConfig; let sensorPush: SensorPushClient; let veridian: VeridianClient; let buffer: BufferManager; +let alertEngine: AlertEngine; +let heartbeat: HeartbeatSender; let lastSync: Date | null = null; +let lastReading: Date | null = null; let isRunning = false; +let sensorCount = 0; async function main() { - console.log('🌱 Veridian Edge Agent starting...'); + console.log('🌱 Veridian Edge Agent v2.0 starting...'); // Load configuration config = loadConfig(); console.log(`šŸ“ Facility: ${config.facilityId}`); - console.log(`šŸ”— Backend: ${config.backendUrl}`); + console.log(`šŸ”— Backend: ${config.server.url}`); + console.log(`šŸ”” Alerts: ${config.alerts.enabled ? 'enabled' : 'disabled'}`); // Initialize clients sensorPush = new SensorPushClient(config.sensorpush); - veridian = new VeridianClient(config.backendUrl, config.backendApiKey); - buffer = new BufferManager(config.bufferMaxRows); + veridian = new VeridianClient(config.server.url, config.server.apiKey); + buffer = new BufferManager(config.storage.maxRows); + + // Initialize alert engine + alertEngine = new AlertEngine(config.alerts, handleAlert); + + // Initialize heartbeat sender + heartbeat = new HeartbeatSender( + config.server.url, + config.server.apiKey, + () => ({ + facilityId: config.facilityId, + edgeId: config.edgeId, + status: 'ok', + sensorCount, + bufferSize: buffer.count(), + lastReading: lastReading?.toISOString(), + }) + ); // Authenticate with SensorPush await sensorPush.authenticate(); @@ -44,6 +71,9 @@ async function main() { })); console.log('šŸ„ Health server running on :3030'); + // Start heartbeat sender + heartbeat.start(config.server.heartbeatIntervalSec); + // Start polling loop isRunning = true; pollLoop(); @@ -73,7 +103,25 @@ async function pollAndSync() { if (readings.length === 0) return; - // 2. Map sensor IDs to room IDs + sensorCount = new Set(readings.map(r => r.sensorId)).size; + lastReading = new Date(); + + // 2. Check each reading against thresholds + for (const reading of readings) { + const mapping = config.sensorMappings.find(m => m.sensorId === reading.sensorId); + const sensorName = mapping?.name || reading.sensorId; + + alertEngine.checkReading({ + sensorId: reading.sensorId, + sensorName, + temperature: reading.temperature, + humidity: reading.humidity, + vpd: reading.vpd, + dewpoint: reading.dewpoint, + }); + } + + // 3. Map sensor IDs to room IDs const mappedReadings = readings .map(r => { const mapping = config.sensorMappings.find(m => m.sensorId === r.sensorId); @@ -89,7 +137,7 @@ async function pollAndSync() { }) .filter((r): r is NonNullable => r !== null); - // 3. Try to sync to backend + // 4. Try to sync to backend try { // First flush any buffered readings const buffered = buffer.getAll(); @@ -97,7 +145,7 @@ async function pollAndSync() { await veridian.postReadings([...buffered, ...mappedReadings]); buffer.clear(); console.log(`šŸ“¤ Synced ${buffered.length + mappedReadings.length} readings (including buffered)`); - } else { + } else if (mappedReadings.length > 0) { await veridian.postReadings(mappedReadings); console.log(`šŸ“¤ Synced ${mappedReadings.length} readings`); } @@ -109,9 +157,42 @@ async function pollAndSync() { } } +/** + * Handle an alert from the alert engine + */ +async function handleAlert(alert: Alert) { + const icon = alert.type === 'HIGH' ? 'šŸ”„' : 'ā„ļø'; + console.log(`${icon} ALERT: ${alert.sensorName} ${alert.metric} ${alert.type} (${alert.value} vs threshold ${alert.threshold})`); + + // Send alert to server for notification fan-out + try { + await fetch(`${config.server.url}/environment/alert`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.server.apiKey}`, + }, + body: JSON.stringify({ + facilityId: config.facilityId, + edgeId: config.edgeId, + alertType: `${alert.metric.toUpperCase()}_${alert.type}`, + sensorId: alert.sensorId, + sensorName: alert.sensorName, + currentValue: alert.value, + threshold: alert.threshold, + timestamp: alert.timestamp.toISOString(), + }), + }); + } catch (error) { + console.warn('āš ļø Failed to send alert to server:', error); + // Alert still logged locally + } +} + function shutdown() { console.log('\nšŸ›‘ Shutting down...'); isRunning = false; + heartbeat.stop(); buffer.close(); process.exit(0); }