From d460808a3f4398aea97671fa9173400193f3e176 Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Fri, 16 Jan 2026 15:23:12 -0500 Subject: [PATCH] u --- README.md | 15 ++++ models.json | 21 ++++++ server.js | 91 ++++++++++++++++-------- tischler_client.js | 171 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 31 deletions(-) create mode 100644 README.md create mode 100644 models.json create mode 100644 tischler_client.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb2384b --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Shelly Agent + +## Running with PM2 + +To start the application using PM2: + +```bash +pm2 start server.js --name shellyagent +``` + +Useful commands: +- **Restart**: `pm2 restart shellyagent` +- **Stop**: `pm2 stop shellyagent` +- **Logs**: `pm2 logs shellyagent` +- **Monitor**: `pm2 monit` diff --git a/models.json b/models.json new file mode 100644 index 0000000..4e2b0d5 --- /dev/null +++ b/models.json @@ -0,0 +1,21 @@ +{ + "SNDC-0D4P10WW": { + "name": "shellyplusrgbwpm", + "gen": 2, + "inputs": 4, + "outputs": [ + "analog", + "analog", + "analog", + "analog" + ] + }, + "SNDM-00100WW": { + "name": "shellyplus010v", + "gen": 2, + "inputs": 2, + "outputs": [ + "analog" + ] + } +} \ No newline at end of file diff --git a/server.js b/server.js index 441685a..09de1dc 100644 --- a/server.js +++ b/server.js @@ -23,10 +23,18 @@ db.serialize(() => { // 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)"); + // Create new events table with 'field' and 'type' columns + db.run("CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY AUTOINCREMENT, mac TEXT, component TEXT, field TEXT, type TEXT, event TEXT, timestamp TEXT)"); }); +// Upstream Client Integration +import { TischlerClient } from './tischler_client.js'; +const UPSTREAM_URL = 'wss://dash.bosewolf.de/agentapi/'; +const UPSTREAM_KEY = 'd2fba4ff8cd18b87735bef34088a5fe78e52049145bc336f30adf2da371ff431'; + +//const upstreamClient = new TischlerClient(UPSTREAM_URL, UPSTREAM_KEY); +//upstreamClient.connect().catch(err => console.error('Failed to connect to upstream:', err)); + // Map to track connection ID to device MAC const connectionDeviceMap = new Map(); @@ -34,12 +42,24 @@ const connectionDeviceMap = new Map(); const macConnectionsMap = new Map(); // Helper to deduplicate stateful events -function checkAndLogEvent(mac, component, field, event, connectionId = null) { +// type: 'enum' (button events), 'range' (level 0-100), 'boolean' (on/off, online) +function checkAndLogEvent(mac, component, field, type, event, connectionId = null) { + // Function to forward event to upstream + const forwardToUpstream = () => { + const channel = `${component}_${field}`; // e.g. light:0_on, system_online + //upstreamClient.sendReadings([{ + // device: mac, + // channel: channel, + // value: event + //}]); + }; + 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()); + const stmt = db.prepare("INSERT INTO events (mac, component, field, type, event, timestamp) VALUES (?, ?, ?, ?, ?, ?)"); + stmt.run(mac, component, field, type, String(event), new Date().toISOString()); stmt.finalize(); + forwardToUpstream(); return; } @@ -55,9 +75,10 @@ function checkAndLogEvent(mac, component, field, event, connectionId = null) { 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()); + const stmt = db.prepare("INSERT INTO events (mac, component, field, type, event, timestamp) VALUES (?, ?, ?, ?, ?, ?)"); + stmt.run(mac, component, field, type, currentEventStr, new Date().toISOString()); stmt.finalize(); + forwardToUpstream(); } else { // console.log(`[ID: ${connectionId}] Duplicate event suppressed: ${event} on ${component} (${field})`); } @@ -121,27 +142,24 @@ wss.on('connection', (ws, req) => { 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') { + if (key.startsWith('switch') && typeof value.output !== 'undefined') { const eventVal = value.output; // true/false if (possibleMac) { - checkAndLogEvent(possibleMac, key, 'on', eventVal, connectionId); + checkAndLogEvent(possibleMac, key, 'on', 'boolean', 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 level for analog light outputs (0 = off, 1-100 = brightness) + if (key.startsWith('light') && typeof value.output !== 'undefined') { + const level = value.output === false ? 0 : (value.brightness || 0); + if (possibleMac) { + checkAndLogEvent(possibleMac, key, 'level', 'range', level, 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); + checkAndLogEvent(possibleMac, key, 'input', 'boolean', eventVal, connectionId); } } } @@ -197,12 +215,18 @@ wss.on('connection', (ws, req) => { stmtDevice.finalize(); // Log online event - checkAndLogEvent(mac, 'system', 'online', true, connectionId); + checkAndLogEvent(mac, 'system', 'online', 'boolean', 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)); + // Only notify if a stable firmware update is available + const updates = data.params.sys.available_updates; + const hasStableUpdate = updates.stable && updates.stable.version; + + if (hasStableUpdate) { + console.log(`[ID: ${connectionId}] Firmware update available for ${deviceId}:`, JSON.stringify(data.params.sys.available_updates)); + } } if (data.method === 'NotifyStatus') { @@ -210,16 +234,20 @@ wss.on('connection', (ws, req) => { 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')) { + // Check for switch components + if (key.startsWith('switch')) { if (typeof value.output !== 'undefined') { - checkAndLogEvent(mac, key, 'on', value.output, connectionId); + checkAndLogEvent(mac, key, 'on', 'boolean', 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); + } + // Check for light components (analog) - log level 0-100 + if (key.startsWith('light')) { + // Calculate level: 0 if explicitly off, otherwise use brightness + if (typeof value.output !== 'undefined' || typeof value.brightness !== 'undefined') { + const isOff = value.output === false; + const level = isOff ? 0 : (value.brightness !== undefined ? value.brightness : null); + if (level !== null) { + checkAndLogEvent(mac, key, 'level', 'range', level, connectionId); } } } @@ -235,7 +263,7 @@ wss.on('connection', (ws, req) => { 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); + checkAndLogEvent(mac, evt.component, 'button', 'enum', evt.event, connectionId); }); } } @@ -243,7 +271,8 @@ wss.on('connection', (ws, req) => { const logFile = path.join(LOG_DIR, `${deviceId}.log`); const timestamp = new Date().toISOString(); - const logEntry = `[${timestamp}] ${msgString}\n`; + const prettyJson = JSON.stringify(data, null, 2); + const logEntry = `[${timestamp}]\n${prettyJson}\n\n`; // Ensure log directory exists (in case it was deleted at runtime) if (!fs.existsSync(LOG_DIR)) { @@ -289,7 +318,7 @@ wss.on('connection', (ws, req) => { db.run("UPDATE devices SET connected = 0 WHERE mac = ?", [mac]); // Log offline event - checkAndLogEvent(mac, 'system', 'online', false, connectionId); + checkAndLogEvent(mac, 'system', 'online', 'boolean', false, connectionId); macConnectionsMap.delete(mac); } else { diff --git a/tischler_client.js b/tischler_client.js new file mode 100644 index 0000000..177137f --- /dev/null +++ b/tischler_client.js @@ -0,0 +1,171 @@ + +import WebSocket from 'ws'; + +/** + * A simple client for the TischlerCtrl WebSocket API. + */ +export class TischlerClient { + /** + * @param {string} url - The WebSocket server URL (e.g., 'ws://localhost:3000') + * @param {string} apiKey - Your Agent API Key + */ + constructor(url, apiKey) { + this.url = url; + this.apiKey = apiKey; + this.ws = null; + this.authenticated = false; + this.onAuthenticated = null; // Callback when authenticated + this.onCommand = null; // Callback for incoming commands + + this.shouldReconnect = true; + this.reconnectInterval = 5000; + this.pingIntervalId = null; + } + + /** + * Connect to the WebSocket server. + * @returns {Promise} Resolves when connected (but not yet authenticated) + */ + connect() { + this.shouldReconnect = true; + return new Promise((resolve, reject) => { + this.ws = new WebSocket(this.url); + + this.ws.on('open', () => { + console.log('[Client] Connected to server.'); + this.startPing(); + this.authenticate(); + resolve(); + }); + + this.ws.on('message', (data) => this.handleMessage(data)); + + this.ws.on('error', (err) => { + console.error('[Client] Connection error:', err.message); + // Don't reject here if we want to rely on 'close' for reconnect logic flow, + // but for the initial Promise usage, we might want to know. + // However, 'close' is always called after 'error'. + }); + + this.ws.on('close', (code, reason) => { + console.log(`[Client] Disconnected. Code: ${code}, Reason: ${reason}`); + this.authenticated = false; + this.stopPing(); + + if (this.shouldReconnect) { + console.log(`[Client] Reconnecting in ${this.reconnectInterval / 1000}s...`); + setTimeout(() => this.connect(), this.reconnectInterval); + } + }); + }); + } + + startPing() { + this.stopPing(); + this.pingIntervalId = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.ping(); + } + }, 30000); + } + + stopPing() { + if (this.pingIntervalId) { + clearInterval(this.pingIntervalId); + this.pingIntervalId = null; + } + } + + /** + * Send authentication message. + */ + authenticate() { + console.log('[Client] Authenticating...'); + this.send({ + type: 'auth', + apiKey: this.apiKey + }); + } + + /** + * Send sensor readings. + * @param {Array} readings - Array of reading objects + * @example + * client.sendReadings([ + * { device: 'temp-sensor-1', channel: 'temp', value: 24.5 }, + * { device: 'temp-sensor-1', channel: 'config', data: { mode: 'eco' } } + * ]); + */ + sendReadings(readings) { + if (!this.authenticated) { + // console.warn('[Client] Cannot send data: Not authenticated.'); + return; + } + + // console.log(`[Client] Sending ${readings.length} readings...`); + this.send({ + type: 'data', + readings: readings + }); + } + + /** + * Handle incoming messages. + */ + handleMessage(data) { + try { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case 'auth': + if (message.success) { + this.authenticated = true; + console.log(`[Client] Authenticated as "${message.name}" (Prefix: ${message.devicePrefix})`); + if (this.onAuthenticated) this.onAuthenticated(); + } else { + console.error('[Client] Authentication failed:', message.error); + this.ws.close(); + } + break; + + case 'ack': + // console.log(`[Client] Server acknowledged ${message.count} readings.`); + break; + + case 'command': + console.log(`[Client] Command received:`, message); + if (this.onCommand) { + this.onCommand(message); + } + break; + + case 'error': + console.error('[Client] Server error:', message.error); + break; + + default: + console.log('[Client] Received:', message); + } + } catch (err) { + console.error('[Client] Failed to parse message:', err); + } + } + + /** + * Helper to send JSON object. + */ + send(obj) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(obj)); + } + } + + /** + * Close connection. + */ + close() { + this.shouldReconnect = false; + this.stopPing(); + if (this.ws) this.ws.close(); + } +}