diff --git a/bun.lock b/bun.lock index 44eb4c3..5510edf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,18 @@ "": { "name": "veridian-edge", "dependencies": { + "@types/pg": "^8.16.0", "better-sqlite3": "^11.0.0", + "hono": "^4.11.3", + "pg": "^8.16.3", }, "devDependencies": { "@types/better-sqlite3": "^7.6.11", "@types/bun": "latest", }, + "optionalDependencies": { + "pigpio": "^3.3.1", + }, }, }, "packages": { @@ -20,6 +26,8 @@ "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], @@ -50,6 +58,8 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -62,12 +72,40 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "nan": ["nan@2.24.0", "", {}, "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], "node-abi": ["node-abi@3.85.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="], + + "pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="], + + "pg-connection-string": ["pg-connection-string@2.9.1", "", {}, "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.10.1", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg=="], + + "pg-protocol": ["pg-protocol@1.10.3", "", {}, "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "pigpio": ["pigpio@3.3.1", "", { "dependencies": { "bindings": "^1.5.0", "nan": "^2.14.2" } }, "sha512-z7J55K14IwWkA+oW5JHzWcgwThFAuJ7IzV3A2//yRm4jJ2DTU0DHIy91DB0siOi12rvvlrIhRetEuAo0ztF/vQ=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -84,6 +122,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -99,5 +139,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], } } diff --git a/package.json b/package.json index 1bbecce..ce34e51 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,13 @@ "test": "bun test" }, "dependencies": { - "better-sqlite3": "^11.0.0" + "@types/pg": "^8.16.0", + "better-sqlite3": "^11.0.0", + "hono": "^4.11.3", + "pg": "^8.16.3" + }, + "optionalDependencies": { + "pigpio": "^3.3.1" }, "devDependencies": { "@types/bun": "latest", diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts new file mode 100644 index 0000000..9dcadce --- /dev/null +++ b/src/dashboard/server.ts @@ -0,0 +1,378 @@ +/** + * Local Dashboard Server + * + * Full web UI for monitoring sensors on the local network. + * Uses Hono for routing and serves a real-time dashboard. + */ + +import { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import type { StorageManager } from '../storage'; +import type { AlertEngine, Alert } from '../alerts'; +import type { EdgeConfig } from '../config'; + +interface DashboardDeps { + config: EdgeConfig; + storage: StorageManager; + getStatus: () => { + connected: boolean; + lastSync: Date | null; + sensorCount: number; + alertCount: number; + recentAlerts: Alert[]; + }; +} + +export function createDashboardServer(deps: DashboardDeps) { + const app = new Hono(); + const { config, storage, getStatus } = deps; + + // API Routes + app.get('/api/status', (c) => { + const status = getStatus(); + return c.json({ + facility: config.facilityId, + edge: config.edgeId, + ...status, + uptime: process.uptime(), + storage: storage.isTimescale() ? 'timescaledb' : 'sqlite', + }); + }); + + app.get('/api/sensors', (c) => { + return c.json(config.sensorMappings); + }); + + app.get('/api/readings/:sensorId', async (c) => { + const sensorId = c.req.param('sensorId'); + const hours = parseInt(c.req.query('hours') || '24'); + const limit = parseInt(c.req.query('limit') || '500'); + + const startTime = new Date(Date.now() - hours * 60 * 60 * 1000); + const readings = await storage.query({ sensorId, startTime, limit }); + + return c.json(readings); + }); + + app.get('/api/stats/:sensorId', async (c) => { + const sensorId = c.req.param('sensorId'); + const hours = parseInt(c.req.query('hours') || '24'); + + const stats = await storage.getStats(sensorId, hours); + return c.json(stats); + }); + + app.get('/api/alerts', (c) => { + const status = getStatus(); + return c.json(status.recentAlerts); + }); + + // Serve the dashboard HTML + app.get('/', (c) => { + return c.html(getDashboardHTML(config)); + }); + + return app; +} + +function getDashboardHTML(config: EdgeConfig): string { + return ` + + + + + Veridian Edge - ${config.edgeId} + + + + +
+

🌱 ${config.edgeId}

+
+
+ Connecting... +
+
+ +
+
+ +
+ +
+
+

Recent Alerts

+
+

No alerts

+
+
+ +
+

System Info

+
+
+
--
+
Uptime
+
+
+
--
+
Storage
+
+
+
--
+
Sensors
+
+
+
+
+
+ + + +`; +} diff --git a/src/physical.ts b/src/physical.ts new file mode 100644 index 0000000..607a955 --- /dev/null +++ b/src/physical.ts @@ -0,0 +1,132 @@ +/** + * GPIO Physical Alerts + * + * Controls buzzer and LED for physical alerts. + * Uses pigpio library for Raspberry Pi GPIO access. + */ + +interface PhysicalAlertConfig { + buzzerPin?: number; // GPIO pin for buzzer + ledPin?: number; // GPIO pin for LED + enabled: boolean; +} + +export class PhysicalAlerts { + private config: PhysicalAlertConfig; + private gpio: any = null; + private buzzer: any = null; + private led: any = null; + private available: boolean = false; + + constructor(config: PhysicalAlertConfig) { + this.config = config; + } + + async init(): Promise { + if (!this.config.enabled) { + console.log('🔕 Physical alerts disabled'); + return; + } + + try { + // Try to load pigpio (only works on Raspberry Pi) + const pigpio = await import('pigpio'); + this.gpio = pigpio.Gpio; + + if (this.config.buzzerPin !== undefined) { + this.buzzer = new this.gpio(this.config.buzzerPin, { mode: this.gpio.OUTPUT }); + console.log(`🔔 Buzzer initialized on GPIO ${this.config.buzzerPin}`); + } + + if (this.config.ledPin !== undefined) { + this.led = new this.gpio(this.config.ledPin, { mode: this.gpio.OUTPUT }); + console.log(`💡 LED initialized on GPIO ${this.config.ledPin}`); + } + + this.available = true; + } catch (error) { + console.warn('⚠️ GPIO not available (not on Raspberry Pi?):', error); + this.available = false; + } + } + + /** + * Trigger alert (buzzer + LED) + */ + async alert(type: 'HIGH' | 'LOW', durationMs: number = 2000): Promise { + if (!this.available) return; + + console.log(`🚨 Physical alert: ${type}`); + + // Turn on buzzer and LED + if (this.buzzer) { + this.buzzer.digitalWrite(1); + } + if (this.led) { + this.led.digitalWrite(1); + } + + // Wait for duration + await Bun.sleep(durationMs); + + // Turn off + if (this.buzzer) { + this.buzzer.digitalWrite(0); + } + if (this.led) { + this.led.digitalWrite(0); + } + } + + /** + * Flash LED pattern for different alert types + */ + async flashPattern(pattern: 'danger' | 'warning' | 'info'): Promise { + if (!this.available || !this.led) return; + + const patterns = { + danger: [200, 200, 200, 200, 200], // Fast flashing + warning: [500, 500, 500], // Medium flashing + info: [1000], // Single long flash + }; + + const durations = patterns[pattern]; + + for (let i = 0; i < durations.length; i++) { + this.led.digitalWrite(1); + await Bun.sleep(durations[i]); + this.led.digitalWrite(0); + if (i < durations.length - 1) { + await Bun.sleep(200); // Gap between flashes + } + } + } + + /** + * Beep buzzer + */ + async beep(count: number = 1, durationMs: number = 100): Promise { + if (!this.available || !this.buzzer) return; + + for (let i = 0; i < count; i++) { + this.buzzer.digitalWrite(1); + await Bun.sleep(durationMs); + this.buzzer.digitalWrite(0); + if (i < count - 1) { + await Bun.sleep(100); // Gap between beeps + } + } + } + + /** + * Turn off all outputs + */ + off(): void { + if (this.buzzer) this.buzzer.digitalWrite(0); + if (this.led) this.led.digitalWrite(0); + } + + isAvailable(): boolean { + return this.available; + } +} diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..77caf03 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,305 @@ +/** + * Time-series Storage Manager + * + * Uses TimescaleDB when available, falls back to SQLite. + * Stores sensor readings with automatic retention cleanup. + */ + +import Database from 'better-sqlite3'; +import { join } from 'path'; +import type { Reading } from './veridian'; + +const SQLITE_PATH = join(process.env.HOME || '~', '.local/share/veridian-edge/readings.db'); + +interface StorageConfig { + retentionDays: number; + maxRows: number; + timescaleUrl?: string; // PostgreSQL connection string +} + +export class StorageManager { + private config: StorageConfig; + private sqlite: Database.Database | null = null; + private timescale: boolean = false; + private pgClient: any = null; + + constructor(config: StorageConfig) { + this.config = config; + } + + async init(): Promise { + // Try TimescaleDB first + if (this.config.timescaleUrl) { + try { + await this.initTimescale(); + this.timescale = true; + console.log('📊 Using TimescaleDB for storage'); + return; + } catch (error) { + console.warn('⚠️ TimescaleDB unavailable, falling back to SQLite:', error); + } + } + + // Fall back to SQLite + this.initSQLite(); + console.log('📊 Using SQLite for storage'); + } + + private async initTimescale(): Promise { + // Dynamic import to avoid bundling pg when not needed + const { Client } = await import('pg'); + this.pgClient = new Client({ connectionString: this.config.timescaleUrl }); + await this.pgClient.connect(); + + // Create hypertable if not exists + await this.pgClient.query(` + CREATE TABLE IF NOT EXISTS sensor_readings ( + time TIMESTAMPTZ NOT NULL, + sensor_id TEXT NOT NULL, + room_id TEXT NOT NULL, + temperature DOUBLE PRECISION, + humidity DOUBLE PRECISION, + dewpoint DOUBLE PRECISION, + vpd DOUBLE PRECISION + ); + `); + + // Try to create hypertable (will fail if already exists, that's ok) + try { + await this.pgClient.query(` + SELECT create_hypertable('sensor_readings', 'time', if_not_exists => TRUE); + `); + } catch { + // Already a hypertable + } + + // Create retention policy + try { + await this.pgClient.query(` + SELECT add_retention_policy('sensor_readings', INTERVAL '${this.config.retentionDays} days', if_not_exists => TRUE); + `); + } catch { + // Policy may already exist + } + } + + private initSQLite(): void { + this.sqlite = new Database(SQLITE_PATH); + this.sqlite.exec(` + CREATE TABLE IF NOT EXISTS sensor_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time TEXT NOT NULL, + sensor_id TEXT NOT NULL, + room_id TEXT NOT NULL, + temperature REAL, + humidity REAL, + dewpoint REAL, + vpd REAL + ); + CREATE INDEX IF NOT EXISTS idx_readings_time ON sensor_readings(time); + CREATE INDEX IF NOT EXISTS idx_readings_sensor ON sensor_readings(sensor_id); + `); + } + + /** + * Store sensor readings + */ + async store(readings: Array): Promise { + if (this.timescale && this.pgClient) { + await this.storeTimescale(readings); + } else if (this.sqlite) { + this.storeSQLite(readings); + } + } + + private async storeTimescale(readings: Array): Promise { + if (readings.length === 0) return; + + const values = readings.map((r, i) => { + const base = i * 7 + 1; + return `($${base}, $${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6})`; + }).join(', '); + + const params = readings.flatMap(r => [ + r.timestamp, + r.sensorId, + r.roomId, + r.temperature, + r.humidity, + r.dewpoint ?? null, + r.vpd ?? null, + ]); + + await this.pgClient.query(` + INSERT INTO sensor_readings (time, sensor_id, room_id, temperature, humidity, dewpoint, vpd) + VALUES ${values} + `, params); + } + + private storeSQLite(readings: Array): void { + if (!this.sqlite || readings.length === 0) return; + + const insert = this.sqlite.prepare(` + INSERT INTO sensor_readings (time, sensor_id, room_id, temperature, humidity, dewpoint, vpd) + VALUES (@time, @sensorId, @roomId, @temperature, @humidity, @dewpoint, @vpd) + `); + + const insertMany = this.sqlite.transaction((items: typeof readings) => { + for (const r of items) { + insert.run({ + time: r.timestamp, + sensorId: r.sensorId, + roomId: r.roomId, + temperature: r.temperature, + humidity: r.humidity, + dewpoint: r.dewpoint ?? null, + vpd: r.vpd ?? null, + }); + } + }); + + insertMany(readings); + this.pruneSQLite(); + } + + /** + * Query readings for a sensor/room + */ + async query(params: { + sensorId?: string; + roomId?: string; + startTime?: Date; + endTime?: Date; + limit?: number; + }): Promise { + if (this.timescale && this.pgClient) { + return this.queryTimescale(params); + } else if (this.sqlite) { + return this.querySQLite(params); + } + return []; + } + + private async queryTimescale(params: any): Promise { + let query = 'SELECT * FROM sensor_readings WHERE 1=1'; + const values: any[] = []; + let paramIdx = 1; + + if (params.sensorId) { + query += ` AND sensor_id = $${paramIdx++}`; + values.push(params.sensorId); + } + if (params.roomId) { + query += ` AND room_id = $${paramIdx++}`; + values.push(params.roomId); + } + if (params.startTime) { + query += ` AND time >= $${paramIdx++}`; + values.push(params.startTime); + } + if (params.endTime) { + query += ` AND time <= $${paramIdx++}`; + values.push(params.endTime); + } + + query += ' ORDER BY time DESC'; + if (params.limit) { + query += ` LIMIT $${paramIdx++}`; + values.push(params.limit); + } + + const result = await this.pgClient.query(query, values); + return result.rows; + } + + private querySQLite(params: any): any[] { + if (!this.sqlite) return []; + + let query = 'SELECT * FROM sensor_readings WHERE 1=1'; + + if (params.sensorId) query += ` AND sensor_id = '${params.sensorId}'`; + if (params.roomId) query += ` AND room_id = '${params.roomId}'`; + if (params.startTime) query += ` AND time >= '${params.startTime.toISOString()}'`; + if (params.endTime) query += ` AND time <= '${params.endTime.toISOString()}'`; + + query += ' ORDER BY time DESC'; + if (params.limit) query += ` LIMIT ${params.limit}`; + + return this.sqlite.prepare(query).all(); + } + + /** + * Get aggregated stats for dashboard + */ + async getStats(sensorId: string, hoursBack: number = 24): Promise<{ + current: { temp: number; humidity: number } | null; + min: { temp: number; humidity: number }; + max: { temp: number; humidity: number }; + avg: { temp: number; humidity: number }; + }> { + const startTime = new Date(Date.now() - hoursBack * 60 * 60 * 1000); + const readings = await this.query({ sensorId, startTime, limit: 1000 }); + + if (readings.length === 0) { + return { + current: null, + min: { temp: 0, humidity: 0 }, + max: { temp: 0, humidity: 0 }, + avg: { temp: 0, humidity: 0 }, + }; + } + + const temps = readings.map(r => r.temperature).filter(Boolean); + const humidities = readings.map(r => r.humidity).filter(Boolean); + + return { + current: { + temp: readings[0].temperature, + humidity: readings[0].humidity, + }, + min: { + temp: Math.min(...temps), + humidity: Math.min(...humidities), + }, + max: { + temp: Math.max(...temps), + humidity: Math.max(...humidities), + }, + avg: { + temp: temps.reduce((a, b) => a + b, 0) / temps.length, + humidity: humidities.reduce((a, b) => a + b, 0) / humidities.length, + }, + }; + } + + private pruneSQLite(): void { + if (!this.sqlite) return; + + // Delete old rows by time + const cutoff = new Date(Date.now() - this.config.retentionDays * 24 * 60 * 60 * 1000); + this.sqlite.prepare(`DELETE FROM sensor_readings WHERE time < ?`).run(cutoff.toISOString()); + + // Also enforce max rows + const count = (this.sqlite.prepare('SELECT COUNT(*) as count FROM sensor_readings').get() as any).count; + if (count > this.config.maxRows) { + const toDelete = count - this.config.maxRows; + this.sqlite.prepare(` + DELETE FROM sensor_readings + WHERE id IN (SELECT id FROM sensor_readings ORDER BY time ASC LIMIT ?) + `).run(toDelete); + } + } + + async close(): Promise { + if (this.pgClient) { + await this.pgClient.end(); + } + if (this.sqlite) { + this.sqlite.close(); + } + } + + isTimescale(): boolean { + return this.timescale; + } +}