homebridge-smartika 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/config.schema.json +71 -0
- package/package.json +57 -0
- package/src/SmartikaCrypto.js +134 -0
- package/src/SmartikaDiscovery.js +177 -0
- package/src/SmartikaHubConnection.js +528 -0
- package/src/SmartikaPlatform.js +379 -0
- package/src/SmartikaProtocol.js +977 -0
- package/src/accessories/SmartikaFanAccessory.js +162 -0
- package/src/accessories/SmartikaLightAccessory.js +203 -0
- package/src/accessories/SmartikaPlugAccessory.js +112 -0
- package/src/index.js +12 -0
- package/src/settings.js +16 -0
- package/tools/smartika-cli.js +1443 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const dgram = require('dgram');
|
|
4
|
+
const EventEmitter = require('events');
|
|
5
|
+
|
|
6
|
+
const BROADCAST_PORT = 4156;
|
|
7
|
+
const DEFAULT_TIMEOUT = 15000; // 15 seconds
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Smartika Hub Discovery
|
|
11
|
+
*
|
|
12
|
+
* Discovers Smartika hubs on the local network via UDP broadcast.
|
|
13
|
+
* Hubs broadcast their presence every ~10 seconds on UDP port 4156.
|
|
14
|
+
*/
|
|
15
|
+
class SmartikaDiscovery extends EventEmitter {
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {Object} options.log - Logger instance
|
|
19
|
+
* @param {number} options.timeout - Discovery timeout in ms
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
super();
|
|
23
|
+
this.log = options.log || console;
|
|
24
|
+
this.timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
25
|
+
this.server = null;
|
|
26
|
+
this.foundHubs = new Map();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discover hubs on the network
|
|
31
|
+
* @returns {Promise<Array>} - Array of discovered hubs
|
|
32
|
+
*/
|
|
33
|
+
discover() {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
this.foundHubs.clear();
|
|
36
|
+
|
|
37
|
+
this.server = dgram.createSocket('udp4');
|
|
38
|
+
|
|
39
|
+
this.server.on('error', (err) => {
|
|
40
|
+
this.log.error('Discovery error:', err.message);
|
|
41
|
+
this.cleanup();
|
|
42
|
+
reject(err);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this.server.on('message', (msg, rinfo) => {
|
|
46
|
+
this.handleMessage(msg, rinfo);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.server.on('listening', () => {
|
|
50
|
+
this.log.debug(`Discovery listening on UDP port ${BROADCAST_PORT}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Start listening
|
|
54
|
+
try {
|
|
55
|
+
this.server.bind(BROADCAST_PORT, () => {
|
|
56
|
+
this.server.setBroadcast(true);
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
this.log.error('Failed to bind discovery port:', err.message);
|
|
60
|
+
reject(err);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Timeout
|
|
65
|
+
setTimeout(() => {
|
|
66
|
+
this.cleanup();
|
|
67
|
+
resolve(Array.from(this.foundHubs.values()));
|
|
68
|
+
}, this.timeout);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Handle incoming broadcast message
|
|
74
|
+
* @param {Buffer} msg
|
|
75
|
+
* @param {Object} rinfo
|
|
76
|
+
*/
|
|
77
|
+
handleMessage(msg, rinfo) {
|
|
78
|
+
// Remove null bytes and trim whitespace
|
|
79
|
+
// eslint-disable-next-line no-control-regex
|
|
80
|
+
const message = msg.toString('utf-8').replace(/\x00/g, '').trim();
|
|
81
|
+
|
|
82
|
+
// Parse "SMARTIKA HUB - {ID}" or "SMARTIKA HUB - BOOTLOADER - {ID}"
|
|
83
|
+
// ID is 16 chars (IEEE address prefix + MAC) or 12 chars (MAC only)
|
|
84
|
+
const match = message.match(/^SMARTIKA HUB(?: - BOOTLOADER)? - ([0-9A-F]{12,16})/i);
|
|
85
|
+
|
|
86
|
+
if (match) {
|
|
87
|
+
const hubId = match[1].toUpperCase();
|
|
88
|
+
const isBootloader = message.includes('BOOTLOADER');
|
|
89
|
+
|
|
90
|
+
// Extract last 6 bytes (12 chars) as MAC address
|
|
91
|
+
const macHex = hubId.slice(-12);
|
|
92
|
+
const macFormatted = macHex.match(/.{2}/g).join(':');
|
|
93
|
+
|
|
94
|
+
const hubInfo = {
|
|
95
|
+
hubId,
|
|
96
|
+
mac: macFormatted,
|
|
97
|
+
macBuffer: Buffer.from(macHex, 'hex'),
|
|
98
|
+
ip: rinfo.address,
|
|
99
|
+
port: rinfo.port,
|
|
100
|
+
bootloader: isBootloader,
|
|
101
|
+
lastSeen: new Date(),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (!this.foundHubs.has(hubId)) {
|
|
105
|
+
this.foundHubs.set(hubId, hubInfo);
|
|
106
|
+
this.log.info(`Discovered hub: ${hubId} at ${rinfo.address}`);
|
|
107
|
+
this.emit('hubFound', hubInfo);
|
|
108
|
+
} else {
|
|
109
|
+
// Update existing hub info
|
|
110
|
+
const existing = this.foundHubs.get(hubId);
|
|
111
|
+
existing.ip = rinfo.address;
|
|
112
|
+
existing.lastSeen = new Date();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Clean up resources
|
|
119
|
+
*/
|
|
120
|
+
cleanup() {
|
|
121
|
+
if (this.server) {
|
|
122
|
+
try {
|
|
123
|
+
this.server.close();
|
|
124
|
+
} catch {
|
|
125
|
+
// Ignore close errors
|
|
126
|
+
}
|
|
127
|
+
this.server = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Start continuous discovery (for background hub monitoring)
|
|
133
|
+
* @param {number} _interval - Check interval in ms (default: 30000)
|
|
134
|
+
*/
|
|
135
|
+
startContinuousDiscovery(_interval = 30000) {
|
|
136
|
+
if (this.continuousServer) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.continuousServer = dgram.createSocket('udp4');
|
|
141
|
+
|
|
142
|
+
this.continuousServer.on('error', (err) => {
|
|
143
|
+
this.log.error('Continuous discovery error:', err.message);
|
|
144
|
+
this.stopContinuousDiscovery();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
this.continuousServer.on('message', (msg, rinfo) => {
|
|
148
|
+
this.handleMessage(msg, rinfo);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
this.continuousServer.bind(BROADCAST_PORT, () => {
|
|
153
|
+
this.continuousServer.setBroadcast(true);
|
|
154
|
+
this.log.debug('Continuous hub discovery started');
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
this.log.error('Failed to start continuous discovery:', err.message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Stop continuous discovery
|
|
163
|
+
*/
|
|
164
|
+
stopContinuousDiscovery() {
|
|
165
|
+
if (this.continuousServer) {
|
|
166
|
+
try {
|
|
167
|
+
this.continuousServer.close();
|
|
168
|
+
} catch {
|
|
169
|
+
// Ignore close errors
|
|
170
|
+
}
|
|
171
|
+
this.continuousServer = null;
|
|
172
|
+
this.log.debug('Continuous hub discovery stopped');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = SmartikaDiscovery;
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const EventEmitter = require('events');
|
|
5
|
+
const crypto = require('./SmartikaCrypto');
|
|
6
|
+
const protocol = require('./SmartikaProtocol');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages the TCP connection to the Smartika hub.
|
|
10
|
+
* Handles encryption, protocol commands, and automatic reconnection.
|
|
11
|
+
*/
|
|
12
|
+
class SmartikaHubConnection extends EventEmitter {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {string} options.host - Hub IP address
|
|
16
|
+
* @param {number} options.port - Hub port (default: 1234)
|
|
17
|
+
* @param {number} options.pollingInterval - Status polling interval in ms
|
|
18
|
+
* @param {Object} options.log - Homebridge logger
|
|
19
|
+
* @param {boolean} options.debug - Enable debug logging
|
|
20
|
+
*/
|
|
21
|
+
constructor(options) {
|
|
22
|
+
super();
|
|
23
|
+
|
|
24
|
+
this.host = options.host;
|
|
25
|
+
this.port = options.port || protocol.HUB_PORT;
|
|
26
|
+
this.pollingInterval = options.pollingInterval || 5000;
|
|
27
|
+
this.log = options.log;
|
|
28
|
+
this.debug = options.debug || false;
|
|
29
|
+
|
|
30
|
+
this.socket = null;
|
|
31
|
+
this.encryptionKey = null;
|
|
32
|
+
this.hubId = null;
|
|
33
|
+
this.connected = false;
|
|
34
|
+
this.reconnecting = false;
|
|
35
|
+
|
|
36
|
+
this.pollingTimer = null;
|
|
37
|
+
this.pingTimer = null;
|
|
38
|
+
this.reconnectTimer = null;
|
|
39
|
+
|
|
40
|
+
// Command queue for handling responses
|
|
41
|
+
this.pendingCommand = null;
|
|
42
|
+
this.commandQueue = [];
|
|
43
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Connect to the Smartika hub
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
connect() {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
if (this.connected) {
|
|
53
|
+
resolve();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.debugLog(`Connecting to hub at ${this.host}:${this.port}...`);
|
|
58
|
+
|
|
59
|
+
this.socket = new net.Socket();
|
|
60
|
+
this.socket.setTimeout(30000);
|
|
61
|
+
|
|
62
|
+
const cleanup = () => {
|
|
63
|
+
this.socket.removeAllListeners('error');
|
|
64
|
+
this.socket.removeAllListeners('timeout');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this.socket.once('error', (err) => {
|
|
68
|
+
cleanup();
|
|
69
|
+
reject(new Error(`Connection failed: ${err.message}`));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
this.socket.once('timeout', () => {
|
|
73
|
+
cleanup();
|
|
74
|
+
this.socket.destroy();
|
|
75
|
+
reject(new Error('Connection timeout'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
this.socket.connect(this.port, this.host, async () => {
|
|
79
|
+
cleanup();
|
|
80
|
+
this.setupSocketHandlers();
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
// Fetch gateway ID to get encryption key
|
|
84
|
+
await this.initializeEncryption();
|
|
85
|
+
this.connected = true;
|
|
86
|
+
this.reconnecting = false;
|
|
87
|
+
this.emit('connected');
|
|
88
|
+
resolve();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
this.socket.destroy();
|
|
91
|
+
reject(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Set up socket event handlers
|
|
99
|
+
*/
|
|
100
|
+
setupSocketHandlers() {
|
|
101
|
+
this.socket.on('data', (data) => {
|
|
102
|
+
this.handleData(data);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.socket.on('close', () => {
|
|
106
|
+
this.connected = false;
|
|
107
|
+
this.emit('disconnected');
|
|
108
|
+
this.scheduleReconnect();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
this.socket.on('error', (err) => {
|
|
112
|
+
this.log.error('Socket error:', err.message);
|
|
113
|
+
this.emit('error', err);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
this.socket.on('timeout', () => {
|
|
117
|
+
this.log.warn('Socket timeout - attempting to keep alive');
|
|
118
|
+
this.ping().catch(() => { });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Initialize encryption by fetching gateway ID
|
|
124
|
+
* @returns {Promise<void>}
|
|
125
|
+
*/
|
|
126
|
+
initializeEncryption() {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const request = protocol.createGatewayIdRequest();
|
|
129
|
+
this.debugLog(`Sending gateway ID request: ${request.toString('hex').toUpperCase()}`);
|
|
130
|
+
|
|
131
|
+
// Gateway ID response is unencrypted
|
|
132
|
+
const handler = (data) => {
|
|
133
|
+
try {
|
|
134
|
+
const result = protocol.parseGatewayIdResponse(data);
|
|
135
|
+
this.hubId = result.hubId;
|
|
136
|
+
this.encryptionKey = crypto.generateKey(result.hubId);
|
|
137
|
+
this.debugLog(`Hub ID: ${result.hubIdHex}`);
|
|
138
|
+
this.debugLog('Encryption key generated');
|
|
139
|
+
resolve();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
reject(error);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
this.socket.once('data', handler);
|
|
146
|
+
this.socket.write(request);
|
|
147
|
+
|
|
148
|
+
// Timeout for gateway ID response
|
|
149
|
+
setTimeout(() => {
|
|
150
|
+
this.socket.removeListener('data', handler);
|
|
151
|
+
reject(new Error('Gateway ID request timeout'));
|
|
152
|
+
}, 5000);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Handle incoming data from the hub
|
|
158
|
+
* @param {Buffer} data
|
|
159
|
+
*/
|
|
160
|
+
handleData(data) {
|
|
161
|
+
// Append to buffer
|
|
162
|
+
this.responseBuffer = Buffer.concat([this.responseBuffer, data]);
|
|
163
|
+
|
|
164
|
+
// Try to process complete packets
|
|
165
|
+
if (this.pendingCommand && this.responseBuffer.length > 0) {
|
|
166
|
+
const { resolve, reject, timeout } = this.pendingCommand;
|
|
167
|
+
clearTimeout(timeout);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// Decrypt the response
|
|
171
|
+
const decrypted = crypto.decrypt(this.responseBuffer, this.encryptionKey);
|
|
172
|
+
this.debugLog(`Response: ${decrypted.toString('hex').toUpperCase()}`);
|
|
173
|
+
|
|
174
|
+
this.pendingCommand = null;
|
|
175
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
176
|
+
resolve(decrypted);
|
|
177
|
+
|
|
178
|
+
// Process next command in queue
|
|
179
|
+
this.processNextCommand();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
this.pendingCommand = null;
|
|
182
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
183
|
+
reject(error);
|
|
184
|
+
|
|
185
|
+
// Process next command in queue even after error
|
|
186
|
+
this.processNextCommand();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Send an encrypted command to the hub
|
|
193
|
+
* Commands are queued and executed sequentially
|
|
194
|
+
* @param {Buffer} request - Protocol request buffer
|
|
195
|
+
* @param {number} timeoutMs - Timeout in milliseconds
|
|
196
|
+
* @returns {Promise<Buffer>} - Decrypted response
|
|
197
|
+
*/
|
|
198
|
+
sendCommand(request, timeoutMs = 10000) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
if (!this.connected || !this.encryptionKey) {
|
|
201
|
+
reject(new Error('Not connected to hub'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Add to queue
|
|
206
|
+
this.commandQueue.push({ request, resolve, reject, timeoutMs });
|
|
207
|
+
|
|
208
|
+
// Process queue if not already processing
|
|
209
|
+
if (!this.pendingCommand) {
|
|
210
|
+
this.processNextCommand();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Process the next command in the queue
|
|
217
|
+
*/
|
|
218
|
+
processNextCommand() {
|
|
219
|
+
if (this.commandQueue.length === 0) {
|
|
220
|
+
this.pendingCommand = null;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { request, resolve, reject, timeoutMs } = this.commandQueue.shift();
|
|
225
|
+
|
|
226
|
+
this.debugLog(`Request: ${request.toString('hex').toUpperCase()}`);
|
|
227
|
+
|
|
228
|
+
const encrypted = crypto.encrypt(request, this.encryptionKey);
|
|
229
|
+
|
|
230
|
+
const timeout = setTimeout(() => {
|
|
231
|
+
this.pendingCommand = null;
|
|
232
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
233
|
+
reject(new Error('Command timeout'));
|
|
234
|
+
// Process next command even after timeout
|
|
235
|
+
this.processNextCommand();
|
|
236
|
+
}, timeoutMs);
|
|
237
|
+
|
|
238
|
+
this.pendingCommand = { resolve, reject, timeout };
|
|
239
|
+
this.responseBuffer = Buffer.alloc(0);
|
|
240
|
+
|
|
241
|
+
this.socket.write(encrypted);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Disconnect from the hub
|
|
246
|
+
*/
|
|
247
|
+
disconnect() {
|
|
248
|
+
this.stopPolling();
|
|
249
|
+
|
|
250
|
+
if (this.reconnectTimer) {
|
|
251
|
+
clearTimeout(this.reconnectTimer);
|
|
252
|
+
this.reconnectTimer = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (this.pingTimer) {
|
|
256
|
+
clearInterval(this.pingTimer);
|
|
257
|
+
this.pingTimer = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (this.socket) {
|
|
261
|
+
this.socket.destroy();
|
|
262
|
+
this.socket = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.connected = false;
|
|
266
|
+
this.encryptionKey = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Schedule a reconnection attempt
|
|
271
|
+
*/
|
|
272
|
+
scheduleReconnect() {
|
|
273
|
+
if (this.reconnecting) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
this.reconnecting = true;
|
|
278
|
+
const delay = 5000;
|
|
279
|
+
|
|
280
|
+
this.log.info(`Reconnecting in ${delay / 1000} seconds...`);
|
|
281
|
+
|
|
282
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
283
|
+
try {
|
|
284
|
+
await this.connect();
|
|
285
|
+
this.log.info('Reconnected to hub');
|
|
286
|
+
this.startPolling();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
this.log.error('Reconnection failed:', error.message);
|
|
289
|
+
this.reconnecting = false;
|
|
290
|
+
this.scheduleReconnect();
|
|
291
|
+
}
|
|
292
|
+
}, delay);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Start polling for device status
|
|
297
|
+
*/
|
|
298
|
+
startPolling() {
|
|
299
|
+
if (this.pollingTimer) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.debugLog(`Starting status polling every ${this.pollingInterval}ms`);
|
|
304
|
+
|
|
305
|
+
// Initial poll
|
|
306
|
+
this.pollDeviceStatus();
|
|
307
|
+
|
|
308
|
+
// Set up polling interval
|
|
309
|
+
this.pollingTimer = setInterval(() => {
|
|
310
|
+
this.pollDeviceStatus();
|
|
311
|
+
}, this.pollingInterval);
|
|
312
|
+
|
|
313
|
+
// Set up ping interval (every 30 seconds)
|
|
314
|
+
this.pingTimer = setInterval(() => {
|
|
315
|
+
this.ping().catch(() => { });
|
|
316
|
+
}, 30000);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Stop polling for device status
|
|
321
|
+
*/
|
|
322
|
+
stopPolling() {
|
|
323
|
+
if (this.pollingTimer) {
|
|
324
|
+
clearInterval(this.pollingTimer);
|
|
325
|
+
this.pollingTimer = null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (this.pingTimer) {
|
|
329
|
+
clearInterval(this.pingTimer);
|
|
330
|
+
this.pingTimer = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Poll device status from the hub
|
|
336
|
+
*/
|
|
337
|
+
async pollDeviceStatus() {
|
|
338
|
+
try {
|
|
339
|
+
this.debugLog('Polling device status...');
|
|
340
|
+
const devices = await this.getDeviceStatus();
|
|
341
|
+
this.debugLog(`Poll returned ${devices.length} device(s)`);
|
|
342
|
+
this.emit('deviceStatusUpdate', devices);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
this.log.warn(`Status poll failed: ${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ========================================================================
|
|
349
|
+
// Protocol Commands
|
|
350
|
+
// ========================================================================
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Send a ping to keep the connection alive
|
|
354
|
+
* @returns {Promise<Object>}
|
|
355
|
+
*/
|
|
356
|
+
async ping() {
|
|
357
|
+
const response = await this.sendCommand(protocol.createPingRequest());
|
|
358
|
+
return protocol.parsePingResponse(response);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get firmware version
|
|
363
|
+
* @returns {Promise<Object>}
|
|
364
|
+
*/
|
|
365
|
+
async getFirmwareVersion() {
|
|
366
|
+
const response = await this.sendCommand(protocol.createFirmwareVersionRequest());
|
|
367
|
+
return protocol.parseFirmwareVersionResponse(response);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* List all registered devices with full info
|
|
372
|
+
* @returns {Promise<Array>}
|
|
373
|
+
*/
|
|
374
|
+
async listDevices() {
|
|
375
|
+
const response = await this.sendCommand(protocol.createDbListDeviceFullRequest());
|
|
376
|
+
return protocol.parseDbListDeviceFullResponse(response);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Discover active devices on the network
|
|
381
|
+
* @returns {Promise<Array>}
|
|
382
|
+
*/
|
|
383
|
+
async discoverDevices() {
|
|
384
|
+
const response = await this.sendCommand(protocol.createDeviceDiscoveryRequest());
|
|
385
|
+
return protocol.parseDeviceDiscoveryResponse(response);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Get status of devices
|
|
390
|
+
* @param {number[]} deviceIds - Device IDs to query (default: broadcast)
|
|
391
|
+
* @returns {Promise<Array>}
|
|
392
|
+
*/
|
|
393
|
+
async getDeviceStatus(deviceIds = [protocol.DEVICE_ID_BROADCAST]) {
|
|
394
|
+
const response = await this.sendCommand(protocol.createDeviceStatusRequest(deviceIds));
|
|
395
|
+
return protocol.parseDeviceStatusResponse(response);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Turn device(s) on or off
|
|
400
|
+
* @param {boolean} on - True to turn on, false to turn off
|
|
401
|
+
* @param {number[]} deviceIds - Device IDs to control
|
|
402
|
+
* @returns {Promise<Object>}
|
|
403
|
+
*/
|
|
404
|
+
async setDevicePower(on, deviceIds) {
|
|
405
|
+
const response = await this.sendCommand(protocol.createDeviceSwitchRequest(on, deviceIds));
|
|
406
|
+
return protocol.parseDeviceSwitchResponse(response);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Set light brightness
|
|
411
|
+
* @param {number} brightness - Brightness level (0-255)
|
|
412
|
+
* @param {number[]} deviceIds - Device IDs to control
|
|
413
|
+
* @returns {Promise<Object>}
|
|
414
|
+
*/
|
|
415
|
+
async setLightBrightness(brightness, deviceIds) {
|
|
416
|
+
const response = await this.sendCommand(protocol.createLightDimRequest(brightness, deviceIds));
|
|
417
|
+
return protocol.parseLightDimResponse(response);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Set light color temperature
|
|
422
|
+
* @param {number} temperature - Temperature (0=warm, 255=cool)
|
|
423
|
+
* @param {number[]} deviceIds - Device IDs to control
|
|
424
|
+
* @returns {Promise<Object>}
|
|
425
|
+
*/
|
|
426
|
+
async setLightTemperature(temperature, deviceIds) {
|
|
427
|
+
const response = await this.sendCommand(protocol.createLightTemperatureRequest(temperature, deviceIds));
|
|
428
|
+
return protocol.parseLightTemperatureResponse(response);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Set fan speed
|
|
433
|
+
* @param {number} speed - Fan speed (0-255)
|
|
434
|
+
* @param {number[]} deviceIds - Device IDs to control
|
|
435
|
+
* @returns {Promise<void>}
|
|
436
|
+
*/
|
|
437
|
+
async setFanSpeed(speed, deviceIds) {
|
|
438
|
+
await this.sendCommand(protocol.createFanControlRequest(speed, deviceIds));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Enable device pairing mode
|
|
443
|
+
* @param {number} duration - Duration in seconds
|
|
444
|
+
* @returns {Promise<Object>}
|
|
445
|
+
*/
|
|
446
|
+
async enablePairing(duration = 0) {
|
|
447
|
+
const response = await this.sendCommand(protocol.createJoinEnableRequest(duration));
|
|
448
|
+
return protocol.parseJoinEnableResponse(response);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Disable device pairing mode
|
|
453
|
+
* @returns {Promise<void>}
|
|
454
|
+
*/
|
|
455
|
+
async disablePairing() {
|
|
456
|
+
await this.sendCommand(protocol.createJoinDisableRequest());
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* List all groups
|
|
461
|
+
* @returns {Promise<Object>}
|
|
462
|
+
*/
|
|
463
|
+
async listGroups() {
|
|
464
|
+
const response = await this.sendCommand(protocol.createGroupListRequest());
|
|
465
|
+
return protocol.parseGroupListResponse(response);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Read group members
|
|
470
|
+
* @param {number} groupId - Group short address
|
|
471
|
+
* @returns {Promise<Object>} - { groupId, deviceIds }
|
|
472
|
+
*/
|
|
473
|
+
async readGroup(groupId) {
|
|
474
|
+
const response = await this.sendCommand(protocol.createGroupReadRequest(groupId));
|
|
475
|
+
return protocol.parseGroupReadResponse(response);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Get all devices that are members of any group
|
|
480
|
+
* @returns {Promise<Set<number>>} - Set of device short addresses that belong to groups
|
|
481
|
+
*/
|
|
482
|
+
async getGroupedDeviceIds() {
|
|
483
|
+
const { groupedDeviceIds } = await this.getGroupsWithMembers();
|
|
484
|
+
return groupedDeviceIds;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Get all groups with their members
|
|
489
|
+
* @returns {Promise<Object>} - { groups: Array<{groupId, deviceIds}>, groupedDeviceIds: Set<number> }
|
|
490
|
+
*/
|
|
491
|
+
async getGroupsWithMembers() {
|
|
492
|
+
const groups = [];
|
|
493
|
+
const groupedDeviceIds = new Set();
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const { groupIds } = await this.listGroups();
|
|
497
|
+
this.debugLog(`Found ${groupIds.length} groups`);
|
|
498
|
+
|
|
499
|
+
for (const groupId of groupIds) {
|
|
500
|
+
try {
|
|
501
|
+
const { deviceIds } = await this.readGroup(groupId);
|
|
502
|
+
this.debugLog(`Group 0x${groupId.toString(16)} has ${deviceIds.length} members: ${deviceIds.map(id => '0x' + id.toString(16)).join(', ')}`);
|
|
503
|
+
|
|
504
|
+
groups.push({ groupId, deviceIds });
|
|
505
|
+
deviceIds.forEach(id => groupedDeviceIds.add(id));
|
|
506
|
+
} catch (err) {
|
|
507
|
+
this.log.warn(`Failed to read group 0x${groupId.toString(16)}: ${err.message}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
} catch (err) {
|
|
511
|
+
this.log.warn(`Failed to list groups: ${err.message}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return { groups, groupedDeviceIds };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Debug logging helper
|
|
519
|
+
* @param {string} message
|
|
520
|
+
*/
|
|
521
|
+
debugLog(message) {
|
|
522
|
+
if (this.debug) {
|
|
523
|
+
this.log.debug(`[Hub] ${message}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
module.exports = SmartikaHubConnection;
|