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.
@@ -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;