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}
+
+
+
+
+
+
+
+
+
+
+`;
+}
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;
+ }
+}