diff --git a/modelPics/S3PL-00112EU.png b/modelPics/S3PL-00112EU.png new file mode 100644 index 0000000..99a31b6 Binary files /dev/null and b/modelPics/S3PL-00112EU.png differ diff --git a/modelPics/S3SW-001P16EU.png b/modelPics/S3SW-001P16EU.png new file mode 100644 index 0000000..4158d5b Binary files /dev/null and b/modelPics/S3SW-001P16EU.png differ diff --git a/modelPics/S3SW-001X8EU.png b/modelPics/S3SW-001X8EU.png new file mode 100644 index 0000000..d6c1b4c Binary files /dev/null and b/modelPics/S3SW-001X8EU.png differ diff --git a/modelPics/S3SW-002P16EU.png b/modelPics/S3SW-002P16EU.png new file mode 100644 index 0000000..195c3c9 Binary files /dev/null and b/modelPics/S3SW-002P16EU.png differ diff --git a/modelPics/SNDC-0D4P10WW.png b/modelPics/SNDC-0D4P10WW.png new file mode 100644 index 0000000..f6266f0 Binary files /dev/null and b/modelPics/SNDC-0D4P10WW.png differ diff --git a/modelPics/SNDM-00100WW.png b/modelPics/SNDM-00100WW.png new file mode 100644 index 0000000..b919f64 Binary files /dev/null and b/modelPics/SNDM-00100WW.png differ diff --git a/server.js b/server.js index a5eb5d2..01840be 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ import path from 'path'; import sqlite3 from 'sqlite3'; import { fileURLToPath } from 'url'; import { initRuleEngine, loadRules, runRules, watchRules } from './rule_engine.js'; +import { broadcastEvent, startStatusServer } from './status_server.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -143,6 +144,7 @@ function checkAndLogEvent(mac, component, field, type, event, connectionId = nul if (connectionId) console.log(`[ID: ${connectionId}] Event logged: ${event} on ${component} (${field})`); db.run("INSERT INTO events (channel_id, event, timestamp) VALUES (?, ?, ?)", [channelId, String(event), new Date().toISOString()]); forwardToUpstream(); + broadcastEvent(mac, component, field, type, event); runRules(mac, component, field, type, event); return; } @@ -160,6 +162,7 @@ function checkAndLogEvent(mac, component, field, type, event, connectionId = nul if (connectionId) console.log(`[ID: ${connectionId}] Status change logged: ${event} on ${component} (${field})`); db.run("INSERT INTO events (channel_id, event, timestamp) VALUES (?, ?, ?)", [channelId, currentEventStr, new Date().toISOString()]); forwardToUpstream(); + broadcastEvent(mac, component, field, type, currentEventStr); runRules(mac, component, field, type, currentEventStr); } }); @@ -179,6 +182,9 @@ loadRules().then(() => { console.error('Error loading rules:', err); }); +// Start status dashboard server +startStatusServer(); + // Global counter for connection IDs let connectionIdCounter = 0; @@ -349,11 +355,17 @@ wss.on('connection', (ws, req) => { if (data.method === 'NotifyEvent') { if (data.params && data.params.events) { const mac = connectionDeviceMap.get(connectionId); - // Even if we don't have MAC from map yet (unlikely for identified device), we can try data.src or skip + // Known button event types to store + const knownButtonEvents = ['single_push', 'double_push', 'triple_push', 'long_push', 'btn_down', 'btn_up']; + if (mac) { data.params.events.forEach(evt => { - // Pass the button event (btn_down/up/etc) as values - checkAndLogEvent(mac, evt.component, 'button', 'enum', evt.event, connectionId); + // Only store known input button events + if (evt.component.startsWith('input') && knownButtonEvents.includes(evt.event)) { + checkAndLogEvent(mac, evt.component, 'button', 'enum', evt.event, connectionId); + } else { + console.log(`[ID: ${connectionId}] Skipped unknown event: ${evt.component} -> ${evt.event}`); + } }); } } diff --git a/status_server.js b/status_server.js new file mode 100644 index 0000000..249eddb --- /dev/null +++ b/status_server.js @@ -0,0 +1,583 @@ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { WebSocketServer } from 'ws'; +import sqlite3 from 'sqlite3'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PORT = 8081; +const MODEL_PICS_DIR = path.join(__dirname, 'modelPics'); + +// SQLite database connection (read-only for status queries) +const db = new sqlite3.Database('devices.db', sqlite3.OPEN_READONLY); + +// WebSocket clients for broadcasting +const wsClients = new Set(); + +// Query initial status data +function getStatusData() { + return new Promise((resolve, reject) => { + const sql = ` + SELECT d.mac, d.model, d.connected, d.last_seen, + c.id as channel_id, c.component, c.field, c.type, + (SELECT e.event FROM events e WHERE e.channel_id = c.id ORDER BY e.id DESC LIMIT 1) as last_event, + (SELECT e.timestamp FROM events e WHERE e.channel_id = c.id ORDER BY e.id DESC LIMIT 1) as last_ts + FROM devices d + LEFT JOIN channels c ON c.mac = d.mac + ORDER BY d.mac, c.component, c.field + `; + db.all(sql, [], (err, rows) => { + if (err) reject(err); + else { + // Group by device + const devices = {}; + for (const row of rows) { + if (!devices[row.mac]) { + devices[row.mac] = { + mac: row.mac, + model: row.model, + connected: row.connected, + last_seen: row.last_seen, + channels: [] + }; + } + if (row.channel_id) { + devices[row.mac].channels.push({ + id: row.channel_id, + component: row.component, + field: row.field, + type: row.type, + event: row.last_event, + timestamp: row.last_ts + }); + } + } + resolve(Object.values(devices)); + } + }); + }); +} + +// Broadcast event to all connected WebSocket clients +export function broadcastEvent(mac, component, field, type, event) { + const message = JSON.stringify({ + type: 'event', + mac, + component, + field, + eventType: type, + event, + timestamp: new Date().toISOString() + }); + for (const client of wsClients) { + if (client.readyState === 1) { // OPEN + client.send(message); + } + } +} + +// HTML Dashboard +const dashboardHTML = ` + + + + + Shelly Status Dashboard + + + + +
+

Shelly Status Dashboard

+

+ + Connecting... +

+
+ +
+
+ + + +

Loading devices...

+
+
+ + + +`; + +// Create HTTP server +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + // Serve model images + if (url.pathname.startsWith('/modelPics/')) { + const filename = path.basename(url.pathname); + const filepath = path.join(MODEL_PICS_DIR, filename); + + if (fs.existsSync(filepath)) { + res.writeHead(200, { 'Content-Type': 'image/png' }); + fs.createReadStream(filepath).pipe(res); + } else { + res.writeHead(404); + res.end('Not found'); + } + return; + } + + // Serve dashboard + if (url.pathname === '/' || url.pathname === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(dashboardHTML); + return; + } + + res.writeHead(404); + res.end('Not found'); +}); + +// Create WebSocket server attached to HTTP server +const wss = new WebSocketServer({ server }); + +wss.on('connection', async (ws) => { + console.log('[Status] Browser client connected'); + wsClients.add(ws); + + // Send initial status data + try { + const devices = await getStatusData(); + ws.send(JSON.stringify({ type: 'init', devices })); + } catch (err) { + console.error('[Status] Error fetching initial data:', err); + } + + ws.on('close', () => { + console.log('[Status] Browser client disconnected'); + wsClients.delete(ws); + }); +}); + +// Start server function +export function startStatusServer() { + server.listen(PORT, () => { + console.log(`[Status] Dashboard server running at http://localhost:${PORT}`); + }); +} + +// Allow running standalone +if (process.argv[1] === fileURLToPath(import.meta.url)) { + startStatusServer(); +}