feat: TimescaleDB storage, GPIO alerts, local dashboard (v3.0)
This commit is contained in:
parent
722b777927
commit
bbaffd4eff
5 changed files with 864 additions and 1 deletions
42
bun.lock
42
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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
378
src/dashboard/server.ts
Normal file
378
src/dashboard/server.ts
Normal 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
132
src/physical.ts
Normal 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
305
src/storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue