u
This commit is contained in:
15
README.md
Normal file
15
README.md
Normal 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
21
models.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
91
server.js
91
server.js
@@ -23,10 +23,18 @@ db.serialize(() => {
|
|||||||
|
|
||||||
// Drop old events table to enforce new schema
|
// Drop old events table to enforce new schema
|
||||||
db.run("DROP TABLE IF EXISTS events");
|
db.run("DROP TABLE IF EXISTS events");
|
||||||
// Create new events table with 'field' column
|
// 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, event TEXT, timestamp TEXT)");
|
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
|
// Map to track connection ID to device MAC
|
||||||
const connectionDeviceMap = new Map();
|
const connectionDeviceMap = new Map();
|
||||||
|
|
||||||
@@ -34,12 +42,24 @@ const connectionDeviceMap = new Map();
|
|||||||
const macConnectionsMap = new Map();
|
const macConnectionsMap = new Map();
|
||||||
|
|
||||||
// Helper to deduplicate stateful events
|
// 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 (field === 'button') {
|
||||||
if (connectionId) console.log(`[ID: ${connectionId}] Event logged: ${event} on ${component} (${field})`);
|
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 (?, ?, ?, ?, ?)");
|
const stmt = db.prepare("INSERT INTO events (mac, component, field, type, event, timestamp) VALUES (?, ?, ?, ?, ?, ?)");
|
||||||
stmt.run(mac, component, field, String(event), new Date().toISOString());
|
stmt.run(mac, component, field, type, String(event), new Date().toISOString());
|
||||||
stmt.finalize();
|
stmt.finalize();
|
||||||
|
forwardToUpstream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,9 +75,10 @@ function checkAndLogEvent(mac, component, field, event, connectionId = null) {
|
|||||||
|
|
||||||
if (!row || row.event !== currentEventStr) {
|
if (!row || row.event !== currentEventStr) {
|
||||||
if (connectionId) console.log(`[ID: ${connectionId}] Status change logged: ${event} on ${component} (${field})`);
|
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 (?, ?, ?, ?, ?)");
|
const stmt = db.prepare("INSERT INTO events (mac, component, field, type, event, timestamp) VALUES (?, ?, ?, ?, ?, ?)");
|
||||||
stmt.run(mac, component, field, currentEventStr, new Date().toISOString());
|
stmt.run(mac, component, field, type, currentEventStr, new Date().toISOString());
|
||||||
stmt.finalize();
|
stmt.finalize();
|
||||||
|
forwardToUpstream();
|
||||||
} else {
|
} else {
|
||||||
// console.log(`[ID: ${connectionId}] Duplicate event suppressed: ${event} on ${component} (${field})`);
|
// 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;
|
const possibleMac = data.params.sys ? data.params.sys.mac : null;
|
||||||
|
|
||||||
// Log initial output states
|
// 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
|
const eventVal = value.output; // true/false
|
||||||
if (possibleMac) {
|
if (possibleMac) {
|
||||||
checkAndLogEvent(possibleMac, key, 'on', eventVal, connectionId);
|
checkAndLogEvent(possibleMac, key, 'on', 'boolean', eventVal, connectionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Log initial brightness
|
// Log initial level for analog light outputs (0 = off, 1-100 = brightness)
|
||||||
if (key.startsWith('light') && typeof value.brightness !== 'undefined') {
|
if (key.startsWith('light') && typeof value.output !== 'undefined') {
|
||||||
// Suppress if output is explicitly false (off)
|
const level = value.output === false ? 0 : (value.brightness || 0);
|
||||||
if (value.output !== false) {
|
if (possibleMac) {
|
||||||
const eventVal = value.brightness;
|
checkAndLogEvent(possibleMac, key, 'level', 'range', level, connectionId);
|
||||||
if (possibleMac) {
|
|
||||||
checkAndLogEvent(possibleMac, key, 'brightness', eventVal, connectionId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Log initial input states (if state is boolean)
|
// Log initial input states (if state is boolean)
|
||||||
if (key.startsWith('input') && typeof value.state === 'boolean') {
|
if (key.startsWith('input') && typeof value.state === 'boolean') {
|
||||||
const eventVal = value.state; // true/false
|
const eventVal = value.state; // true/false
|
||||||
if (possibleMac) {
|
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();
|
stmtDevice.finalize();
|
||||||
|
|
||||||
// Log online event
|
// 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) {
|
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') {
|
if (data.method === 'NotifyStatus') {
|
||||||
@@ -210,16 +234,20 @@ wss.on('connection', (ws, req) => {
|
|||||||
const mac = connectionDeviceMap.get(connectionId);
|
const mac = connectionDeviceMap.get(connectionId);
|
||||||
if (mac) {
|
if (mac) {
|
||||||
for (const [key, value] of Object.entries(data.params)) {
|
for (const [key, value] of Object.entries(data.params)) {
|
||||||
// Check for components like light:0, switch:0 etc.
|
// Check for switch components
|
||||||
if (key.startsWith('light') || key.startsWith('switch')) {
|
if (key.startsWith('switch')) {
|
||||||
if (typeof value.output !== 'undefined') {
|
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
|
// Check for light components (analog) - log level 0-100
|
||||||
if (typeof value.brightness !== 'undefined') {
|
if (key.startsWith('light')) {
|
||||||
if (value.output !== false) {
|
// Calculate level: 0 if explicitly off, otherwise use brightness
|
||||||
checkAndLogEvent(mac, key, 'brightness', value.brightness, connectionId);
|
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) {
|
if (mac) {
|
||||||
data.params.events.forEach(evt => {
|
data.params.events.forEach(evt => {
|
||||||
// Pass the button event (btn_down/up/etc) as values
|
// 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 logFile = path.join(LOG_DIR, `${deviceId}.log`);
|
||||||
const timestamp = new Date().toISOString();
|
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)
|
// Ensure log directory exists (in case it was deleted at runtime)
|
||||||
if (!fs.existsSync(LOG_DIR)) {
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
@@ -289,7 +318,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
db.run("UPDATE devices SET connected = 0 WHERE mac = ?", [mac]);
|
db.run("UPDATE devices SET connected = 0 WHERE mac = ?", [mac]);
|
||||||
|
|
||||||
// Log offline event
|
// Log offline event
|
||||||
checkAndLogEvent(mac, 'system', 'online', false, connectionId);
|
checkAndLogEvent(mac, 'system', 'online', 'boolean', false, connectionId);
|
||||||
|
|
||||||
macConnectionsMap.delete(mac);
|
macConnectionsMap.delete(mac);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
171
tischler_client.js
Normal file
171
tischler_client.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user