feat: TimescaleDB storage, GPIO alerts, local dashboard (v3.0)

This commit is contained in:
fullsizemalt 2026-01-02 00:03:24 -08:00
parent 722b777927
commit bbaffd4eff
5 changed files with 864 additions and 1 deletions

View file

@ -5,12 +5,18 @@
"": { "": {
"name": "veridian-edge", "name": "veridian-edge",
"dependencies": { "dependencies": {
"@types/pg": "^8.16.0",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
"hono": "^4.11.3",
"pg": "^8.16.3",
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11", "@types/better-sqlite3": "^7.6.11",
"@types/bun": "latest", "@types/bun": "latest",
}, },
"optionalDependencies": {
"pigpio": "^3.3.1",
},
}, },
}, },
"packages": { "packages": {
@ -20,6 +26,8 @@
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@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=="], "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=="], "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=="], "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=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
} }
} }

View file

@ -9,7 +9,13 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "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": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",

378
src/dashboard/server.ts Normal file
View file

@ -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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Veridian Edge - ${config.edgeId}</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
padding: 1.5rem 2rem;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #22c55e;
}
.header .status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #22c55e;
animation: pulse 2s infinite;
}
.status-dot.error { background: #ef4444; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 1.5rem;
border: 1px solid #334155;
}
.card h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
margin-bottom: 1rem;
}
.sensor-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sensor-name {
font-size: 1.25rem;
font-weight: 600;
}
.readings {
display: flex;
gap: 2rem;
}
.reading {
flex: 1;
}
.reading-label {
font-size: 0.75rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.reading-value {
font-size: 2rem;
font-weight: 700;
}
.reading-value.temp { color: #f59e0b; }
.reading-value.humidity { color: #3b82f6; }
.chart-container {
height: 200px;
margin-top: 1rem;
}
.alerts-list {
max-height: 300px;
overflow-y: auto;
}
.alert-item {
padding: 0.75rem;
background: #0f172a;
border-radius: 8px;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.alert-item.high { border-left: 3px solid #ef4444; }
.alert-item.low { border-left: 3px solid #3b82f6; }
.alert-text {
font-size: 0.875rem;
}
.alert-time {
font-size: 0.75rem;
color: #64748b;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 600;
}
.stat-label {
font-size: 0.75rem;
color: #64748b;
}
</style>
</head>
<body>
<header class="header">
<h1>🌱 ${config.edgeId}</h1>
<div class="status">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Connecting...</span>
</div>
</header>
<div class="container">
<div class="grid" id="sensors-grid">
<!-- Sensor cards will be inserted here -->
</div>
<div class="grid" style="margin-top: 1.5rem;">
<div class="card">
<h2>Recent Alerts</h2>
<div class="alerts-list" id="alerts-list">
<p style="color: #64748b;">No alerts</p>
</div>
</div>
<div class="card">
<h2>System Info</h2>
<div class="stats-grid">
<div>
<div class="stat-value" id="uptime">--</div>
<div class="stat-label">Uptime</div>
</div>
<div>
<div class="stat-value" id="storage-type">--</div>
<div class="stat-label">Storage</div>
</div>
<div>
<div class="stat-value" id="sensor-count">--</div>
<div class="stat-label">Sensors</div>
</div>
</div>
</div>
</div>
</div>
<script>
const charts = {};
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('status-dot').className = 'status-dot' + (data.connected ? '' : ' error');
document.getElementById('status-text').textContent = data.connected ? 'Connected' : 'Disconnected';
document.getElementById('uptime').textContent = formatUptime(data.uptime);
document.getElementById('storage-type').textContent = data.storage;
document.getElementById('sensor-count').textContent = data.sensorCount;
updateAlerts(data.recentAlerts || []);
} catch (e) {
document.getElementById('status-dot').className = 'status-dot error';
document.getElementById('status-text').textContent = 'Error';
}
}
function updateAlerts(alerts) {
const list = document.getElementById('alerts-list');
if (alerts.length === 0) {
list.innerHTML = '<p style="color: #64748b;">No alerts</p>';
return;
}
list.innerHTML = alerts.slice(0, 10).map(a => \`
<div class="alert-item \${a.type.toLowerCase()}">
<div class="alert-text">\${a.sensorName}: \${a.metric} \${a.type} (\${a.value.toFixed(1)})</div>
<div class="alert-time">\${new Date(a.timestamp).toLocaleTimeString()}</div>
</div>
\`).join('');
}
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
return hours > 0 ? \`\${hours}h \${mins}m\` : \`\${mins}m\`;
}
async function initSensors() {
const res = await fetch('/api/sensors');
const sensors = await res.json();
const grid = document.getElementById('sensors-grid');
grid.innerHTML = sensors.map(s => \`
<div class="card sensor-card" data-sensor="\${s.sensorId}">
<div class="sensor-name">\${s.name}</div>
<div class="readings">
<div class="reading">
<div class="reading-label">Temperature</div>
<div class="reading-value temp" id="temp-\${s.sensorId}">--°F</div>
</div>
<div class="reading">
<div class="reading-label">Humidity</div>
<div class="reading-value humidity" id="hum-\${s.sensorId}">--%</div>
</div>
</div>
<div class="chart-container">
<canvas id="chart-\${s.sensorId}"></canvas>
</div>
</div>
\`).join('');
for (const sensor of sensors) {
await initChart(sensor.sensorId);
}
}
async function initChart(sensorId) {
const res = await fetch(\`/api/readings/\${sensorId}?hours=6&limit=100\`);
const readings = await res.json();
const ctx = document.getElementById(\`chart-\${sensorId}\`);
if (!ctx) return;
const data = readings.reverse();
charts[sensorId] = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(r => new Date(r.time).toLocaleTimeString()),
datasets: [{
label: 'Temp',
data: data.map(r => r.temperature),
borderColor: '#f59e0b',
tension: 0.3,
pointRadius: 0,
}, {
label: 'Humidity',
data: data.map(r => r.humidity),
borderColor: '#3b82f6',
tension: 0.3,
pointRadius: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { display: false },
y: { grid: { color: '#334155' }, ticks: { color: '#64748b' } }
}
}
});
if (data.length > 0) {
document.getElementById(\`temp-\${sensorId}\`).textContent = \`\${data[data.length-1].temperature.toFixed(1)}°F\`;
document.getElementById(\`hum-\${sensorId}\`).textContent = \`\${data[data.length-1].humidity.toFixed(1)}%\`;
}
}
initSensors();
fetchStatus();
setInterval(fetchStatus, 10000);
</script>
</body>
</html>`;
}

132
src/physical.ts Normal file
View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}

305
src/storage.ts Normal file
View file

@ -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<void> {
// 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<void> {
// 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<Reading & { sensorId: string }>): Promise<void> {
if (this.timescale && this.pgClient) {
await this.storeTimescale(readings);
} else if (this.sqlite) {
this.storeSQLite(readings);
}
}
private async storeTimescale(readings: Array<Reading & { sensorId: string }>): Promise<void> {
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<Reading & { sensorId: string }>): 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<any[]> {
if (this.timescale && this.pgClient) {
return this.queryTimescale(params);
} else if (this.sqlite) {
return this.querySQLite(params);
}
return [];
}
private async queryTimescale(params: any): Promise<any[]> {
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<void> {
if (this.pgClient) {
await this.pgClient.end();
}
if (this.sqlite) {
this.sqlite.close();
}
}
isTimescale(): boolean {
return this.timescale;
}
}