This commit is contained in:
sebseb7
2026-01-10 23:25:45 -05:00
commit a8e403141b
5 changed files with 3497 additions and 0 deletions

329
server.js Normal file
View File

@@ -0,0 +1,329 @@
import { WebSocketServer } from 'ws';
import fs from 'fs';
import path from 'path';
import sqlite3 from 'sqlite3';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LOG_DIR = path.join(__dirname, 'logs');
// Ensure logs directory exists
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR);
}
// Initialize SQLite database
const db = new sqlite3.Database('devices.db');
db.serialize(() => {
db.run("CREATE TABLE IF NOT EXISTS devices (mac TEXT PRIMARY KEY, model TEXT, connected INTEGER, last_seen TEXT)");
// Reset all devices to disconnected on server start
db.run("UPDATE devices SET connected = 0");
// Drop old events table to enforce new schema
db.run("DROP TABLE IF EXISTS events");
// Create new events table with 'field' column
db.run("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, mac TEXT, component TEXT, field TEXT, event TEXT, timestamp TEXT)");
});
// Map to track connection ID to device MAC
const connectionDeviceMap = new Map();
// Map to track all active connection IDs for a given MAC
const macConnectionsMap = new Map();
// Helper to deduplicate stateful events
function checkAndLogEvent(mac, component, field, event, connectionId = null) {
if (field === 'button') {
if (connectionId) console.log(`[ID: ${connectionId}] Event logged: ${event} on ${component} (${field})`);
const stmt = db.prepare("INSERT INTO events (mac, component, field, event, timestamp) VALUES (?, ?, ?, ?, ?)");
stmt.run(mac, component, field, String(event), new Date().toISOString());
stmt.finalize();
return;
}
// Deduplication key: mac + component + field
db.get("SELECT event FROM events WHERE mac = ? AND component = ? AND field = ? ORDER BY id DESC LIMIT 1", [mac, component, field], (err, row) => {
if (err) {
console.error("Error querying events for deduplication:", err);
return;
}
// Compare stringified versions to be safe (e.g. "true" vs "true")
const currentEventStr = String(event);
if (!row || row.event !== currentEventStr) {
if (connectionId) console.log(`[ID: ${connectionId}] Status change logged: ${event} on ${component} (${field})`);
const stmt = db.prepare("INSERT INTO events (mac, component, field, event, timestamp) VALUES (?, ?, ?, ?, ?)");
stmt.run(mac, component, field, currentEventStr, new Date().toISOString());
stmt.finalize();
} else {
// console.log(`[ID: ${connectionId}] Duplicate event suppressed: ${event} on ${component} (${field})`);
}
});
}
const wss = new WebSocketServer({ port: 8080 });
console.log('Shelly Agent Server listening on port 8080');
// Global counter for connection IDs
let connectionIdCounter = 0;
const interval = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
console.log(`[ID: ${ws.connectionId}] Heartbeat missed. Terminating connection...`);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(interval);
});
wss.on('connection', (ws, req) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
const ip = req.socket.remoteAddress;
const connectionId = ++connectionIdCounter;
ws.connectionId = connectionId;
console.log(`New connection from ${ip} (ID: ${connectionId})`);
ws.on('message', (message) => {
const msgString = message.toString();
// console.log(`Received: ${msgString}`);
try {
const data = JSON.parse(msgString);
// Try to identify the device from 'src'
let deviceId = 'unknown_device';
if (data.src) {
deviceId = data.src;
}
if (data.method === 'NotifyFullStatus') {
console.log(`[ID: ${connectionId}] Device identified: ${deviceId}`);
// Connect event (preliminary, we confirm MAC later but good to track 'device' online if we trust src)
// Actually, let's wait until we have the confirmed MAC from RPC or use src if it looks like a MAC-based ID
// Ideally we log 'online' when we insert into the devices table.
if (data.params) {
for (const [key, value] of Object.entries(data.params)) {
const possibleMac = data.params.sys ? data.params.sys.mac : null;
// Log initial output states
if ((key.startsWith('light') || key.startsWith('switch')) && typeof value.output !== 'undefined') {
const eventVal = value.output; // true/false
if (possibleMac) {
checkAndLogEvent(possibleMac, key, 'on', eventVal, connectionId);
}
}
// Log initial brightness
if (key.startsWith('light') && typeof value.brightness !== 'undefined') {
// Suppress if output is explicitly false (off)
if (value.output !== false) {
const eventVal = value.brightness;
if (possibleMac) {
checkAndLogEvent(possibleMac, key, 'brightness', eventVal, connectionId);
}
}
}
// Log initial input states (if state is boolean)
if (key.startsWith('input') && typeof value.state === 'boolean') {
const eventVal = value.state; // true/false
if (possibleMac) {
checkAndLogEvent(possibleMac, key, 'input', eventVal, connectionId);
}
}
}
}
// Request device info to populate database
const rpcRequest = {
id: Math.floor(Math.random() * 10000),
src: "server",
method: "Shelly.GetDeviceInfo"
};
ws.send(JSON.stringify(rpcRequest));
}
// Handle properties for device info update
if (data.result && (data.result.mac || (data.result.device_id && data.result.model))) {
// Adjust based on actual Shelly RPC response structure.
// Shelly.GetDeviceInfo usually returns { mac, model, gen, ... } or { name, id, mac, model, gen, ... }
// The 'mac' might be in data.result.mac or data.result.ethernet.mac etc depending on firmware/generation.
// Assuming flat structure or checking common fields.
const mac = data.result.mac || data.result.id; // Fallback
const model = data.result.model || data.result.app; // Fallback
if (mac && model) {
console.log(`[ID: ${connectionId}] Updating device info: MAC=${mac}, Model=${model}`);
connectionDeviceMap.set(connectionId, mac);
// Check for and drop old connections for this MAC
if (macConnectionsMap.has(mac)) {
const existingConnections = macConnectionsMap.get(mac);
existingConnections.forEach(oldConnId => {
if (oldConnId !== connectionId) {
console.log(`[ID: ${connectionId}] New connection for MAC=${mac}. Dropping old connection ID: ${oldConnId}`);
// Find the socket to terminate
wss.clients.forEach(client => {
if (client.connectionId === oldConnId) {
client.terminate();
}
});
// Cleanup maps will happen in 'close' handler of that socket
}
});
}
if (!macConnectionsMap.has(mac)) {
macConnectionsMap.set(mac, new Set());
}
macConnectionsMap.get(mac).add(connectionId);
const stmtDevice = db.prepare("INSERT OR REPLACE INTO devices (mac, model, connected, last_seen) VALUES (?, ?, ?, ?)");
stmtDevice.run(mac, model, 1, new Date().toISOString());
stmtDevice.finalize();
// Log online event
checkAndLogEvent(mac, 'system', 'online', true, connectionId);
}
}
if (data.params && data.params.sys && data.params.sys.available_updates) {
console.log(`[ID: ${connectionId}] Firmware update available for ${deviceId}:`, JSON.stringify(data.params.sys.available_updates));
}
if (data.method === 'NotifyStatus') {
if (data.params) {
const mac = connectionDeviceMap.get(connectionId);
if (mac) {
for (const [key, value] of Object.entries(data.params)) {
// Check for components like light:0, switch:0 etc.
if (key.startsWith('light') || key.startsWith('switch')) {
if (typeof value.output !== 'undefined') {
checkAndLogEvent(mac, key, 'on', value.output, connectionId);
}
// Log brightness changes, suppressed if turning off
if (typeof value.brightness !== 'undefined') {
if (value.output !== false) {
checkAndLogEvent(mac, key, 'brightness', value.brightness, connectionId);
}
}
}
}
}
}
}
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
if (mac) {
data.params.events.forEach(evt => {
// Pass the button event (btn_down/up/etc) as values
checkAndLogEvent(mac, evt.component, 'button', evt.event, connectionId);
});
}
}
}
const logFile = path.join(LOG_DIR, `${deviceId}.log`);
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${msgString}\n`;
// Ensure log directory exists (in case it was deleted at runtime)
if (!fs.existsSync(LOG_DIR)) {
try {
fs.mkdirSync(LOG_DIR, { recursive: true });
} catch (err) {
console.error(`Error recreating log directory ${LOG_DIR}:`, err);
}
}
fs.appendFile(logFile, logEntry, (err) => {
if (err) {
console.error(`Error writing to log file ${logFile}:`, err);
}
});
} catch (e) {
console.error('Error parsing message:', e);
// Ensure log directory exists before error logging
if (!fs.existsSync(LOG_DIR)) {
try { fs.mkdirSync(LOG_DIR, { recursive: true }); } catch (e) { }
}
// Log raw message to a generic error log or unknown
fs.appendFile(path.join(LOG_DIR, 'parsing_errors.log'), `[${new Date().toISOString()}] ${msgString}\n`, () => { });
}
});
ws.on('close', (code, reason) => {
console.log(`Connection closed (ID: ${connectionId}). Code: ${code}, Reason: ${reason}`);
const mac = connectionDeviceMap.get(connectionId);
if (mac) {
connectionDeviceMap.delete(connectionId);
const connections = macConnectionsMap.get(mac);
if (connections) {
connections.delete(connectionId);
if (connections.size === 0) {
console.log(`All connections closed for MAC=${mac}. Marking as disconnected.`);
db.run("UPDATE devices SET connected = 0 WHERE mac = ?", [mac]);
// Log offline event
checkAndLogEvent(mac, 'system', 'online', false, connectionId);
macConnectionsMap.delete(mac);
} else {
console.log(`Connection closed for MAC=${mac}, but ${connections.size} other connection(s) remain active.`);
}
} else {
// Fallback for safety, though technically shouldn't happen if logic is correct
db.run("UPDATE devices SET connected = 0 WHERE mac = ?", [mac]);
}
}
});
ws.on('error', (err) => {
console.error(`WebSocket error: ${err}`);
});
});
// Graceful shutdown
function shutdown() {
console.log('Shutting down server...');
wss.clients.forEach(ws => ws.terminate());
db.serialize(() => {
db.run("UPDATE devices SET connected = 0", (err) => {
if (err) {
console.error('Error updating DB on shutdown:', err);
} else {
console.log('All devices marked as disconnected.');
}
db.close();
process.exit(0);
});
});
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);