This commit is contained in:
sebseb7
2026-01-16 15:23:12 -05:00
parent a8e403141b
commit d460808a3f
4 changed files with 267 additions and 31 deletions

15
README.md Normal file
View File

@@ -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`

21
models.json Normal file
View File

@@ -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"
]
}
}

View File

@@ -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 {

171
tischler_client.js Normal file
View File

@@ -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<void>} 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<Object>} 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();
}
}