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,379 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PLATFORM_NAME, PLUGIN_NAME } = require('./settings');
|
|
4
|
+
const SmartikaHubConnection = require('./SmartikaHubConnection');
|
|
5
|
+
const SmartikaDiscovery = require('./SmartikaDiscovery');
|
|
6
|
+
const SmartikaLightAccessory = require('./accessories/SmartikaLightAccessory');
|
|
7
|
+
const SmartikaFanAccessory = require('./accessories/SmartikaFanAccessory');
|
|
8
|
+
const SmartikaPlugAccessory = require('./accessories/SmartikaPlugAccessory');
|
|
9
|
+
const protocol = require('./SmartikaProtocol');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Smartika Platform Plugin for Homebridge
|
|
13
|
+
*
|
|
14
|
+
* This platform dynamically discovers and registers Smartika devices as HomeKit accessories.
|
|
15
|
+
* Communication is entirely local via TCP with AES-128-CBC encryption.
|
|
16
|
+
*/
|
|
17
|
+
class SmartikaPlatform {
|
|
18
|
+
/**
|
|
19
|
+
* @param {import('homebridge').Logger} log
|
|
20
|
+
* @param {import('homebridge').PlatformConfig} config
|
|
21
|
+
* @param {import('homebridge').API} api
|
|
22
|
+
*/
|
|
23
|
+
constructor(log, config, api) {
|
|
24
|
+
this.log = log;
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.api = api;
|
|
27
|
+
|
|
28
|
+
// Store restored cached accessories
|
|
29
|
+
this.accessories = new Map();
|
|
30
|
+
|
|
31
|
+
// Hub connection instance
|
|
32
|
+
this.hub = null;
|
|
33
|
+
|
|
34
|
+
// Discovery instance
|
|
35
|
+
this.discovery = null;
|
|
36
|
+
|
|
37
|
+
// Device accessory handlers
|
|
38
|
+
this.deviceHandlers = new Map();
|
|
39
|
+
|
|
40
|
+
// Validate configuration
|
|
41
|
+
if (!config) {
|
|
42
|
+
this.log.error('No configuration found for Smartika platform');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.log.info('Smartika Platform initializing...');
|
|
47
|
+
if (config.hubHost) {
|
|
48
|
+
this.log.info(`Hub IP: ${config.hubHost}`);
|
|
49
|
+
} else {
|
|
50
|
+
this.log.info('No hub IP configured - will use auto-discovery');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Wait for Homebridge to finish launching before initializing
|
|
54
|
+
this.api.on('didFinishLaunching', () => {
|
|
55
|
+
this.log.debug('didFinishLaunching');
|
|
56
|
+
this.initializeHub();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Handle shutdown
|
|
60
|
+
this.api.on('shutdown', () => {
|
|
61
|
+
this.log.info('Shutting down Smartika platform...');
|
|
62
|
+
if (this.hub) {
|
|
63
|
+
this.hub.disconnect();
|
|
64
|
+
}
|
|
65
|
+
if (this.discovery) {
|
|
66
|
+
this.discovery.stopContinuousDiscovery();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Initialize connection to Smartika hub and discover devices
|
|
73
|
+
*/
|
|
74
|
+
async initializeHub() {
|
|
75
|
+
let hubHost = this.config.hubHost;
|
|
76
|
+
|
|
77
|
+
// If no hub IP configured, try auto-discovery
|
|
78
|
+
if (!hubHost) {
|
|
79
|
+
this.log.info('Starting hub auto-discovery...');
|
|
80
|
+
hubHost = await this.discoverHub();
|
|
81
|
+
|
|
82
|
+
if (!hubHost) {
|
|
83
|
+
this.log.error('No Smartika hub found on the network. Please configure hubHost manually.');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Create hub connection
|
|
90
|
+
this.hub = new SmartikaHubConnection({
|
|
91
|
+
host: hubHost,
|
|
92
|
+
port: this.config.hubPort || protocol.HUB_PORT,
|
|
93
|
+
pollingInterval: this.config.pollingInterval || 5000,
|
|
94
|
+
log: this.log,
|
|
95
|
+
debug: this.config.debug || false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Set up event handlers
|
|
99
|
+
this.hub.on('connected', () => {
|
|
100
|
+
this.log.info(`Connected to Smartika hub at ${hubHost}`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.hub.on('disconnected', () => {
|
|
104
|
+
this.log.warn('Disconnected from Smartika hub');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
this.hub.on('deviceStatusUpdate', (devices) => {
|
|
108
|
+
this.handleDeviceStatusUpdate(devices);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
this.hub.on('error', (error) => {
|
|
112
|
+
this.log.error('Hub error:', error.message);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Connect to hub
|
|
116
|
+
await this.hub.connect();
|
|
117
|
+
|
|
118
|
+
// Discover devices
|
|
119
|
+
await this.discoverDevices();
|
|
120
|
+
|
|
121
|
+
// Start polling for status updates
|
|
122
|
+
this.hub.startPolling();
|
|
123
|
+
|
|
124
|
+
} catch (error) {
|
|
125
|
+
this.log.error('Failed to initialize hub:', error.message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Discover Smartika hub on the network using UDP broadcast
|
|
131
|
+
* @returns {Promise<string|null>} - Hub IP address or null if not found
|
|
132
|
+
*/
|
|
133
|
+
async discoverHub() {
|
|
134
|
+
try {
|
|
135
|
+
this.discovery = new SmartikaDiscovery({
|
|
136
|
+
log: this.log,
|
|
137
|
+
timeout: 15000, // 15 seconds for discovery
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this.discovery.on('hubFound', (hubInfo) => {
|
|
141
|
+
this.log.info(`Found hub: ${hubInfo.hubId} at ${hubInfo.ip}`);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const hubs = await this.discovery.discover();
|
|
145
|
+
|
|
146
|
+
if (hubs.length === 0) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Use the first hub found (or filter by hubId if configured)
|
|
151
|
+
const hub = hubs[0];
|
|
152
|
+
this.log.info(`Using hub at ${hub.ip} (${hub.hubId})`);
|
|
153
|
+
return hub.ip;
|
|
154
|
+
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.log.error('Hub discovery failed:', error.message);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Discover devices from the hub and register them as accessories
|
|
163
|
+
*/
|
|
164
|
+
async discoverDevices() {
|
|
165
|
+
try {
|
|
166
|
+
this.log.info('Discovering devices...');
|
|
167
|
+
|
|
168
|
+
// Get registered devices from hub database
|
|
169
|
+
const devices = await this.hub.listDevices();
|
|
170
|
+
this.log.info(`Found ${devices.length} registered device(s)`);
|
|
171
|
+
|
|
172
|
+
// Get groups and their members
|
|
173
|
+
const { groups, groupedDeviceIds } = await this.hub.getGroupsWithMembers();
|
|
174
|
+
if (groups.length > 0) {
|
|
175
|
+
this.log.info(`Found ${groups.length} group(s) containing ${groupedDeviceIds.size} device(s)`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Track which accessories we found
|
|
179
|
+
const foundUUIDs = new Set();
|
|
180
|
+
|
|
181
|
+
// First, add groups as accessories (virtual devices)
|
|
182
|
+
for (const group of groups) {
|
|
183
|
+
const uuid = this.api.hap.uuid.generate(`smartika-group-${group.groupId}`);
|
|
184
|
+
foundUUIDs.add(uuid);
|
|
185
|
+
|
|
186
|
+
// Create a virtual device object for the group
|
|
187
|
+
const groupDevice = {
|
|
188
|
+
shortAddress: group.groupId,
|
|
189
|
+
deviceType: 0x40000001, // Virtual Light (most groups are lights)
|
|
190
|
+
typeName: `Group ${group.groupId.toString(16).toUpperCase()}`,
|
|
191
|
+
category: protocol.DEVICE_CATEGORY.LIGHT,
|
|
192
|
+
isGroup: true,
|
|
193
|
+
memberCount: group.deviceIds.length,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const existingAccessory = this.accessories.get(uuid);
|
|
197
|
+
|
|
198
|
+
if (existingAccessory) {
|
|
199
|
+
this.log.info(`Restoring cached group: ${groupDevice.typeName} (${group.deviceIds.length} members)`);
|
|
200
|
+
existingAccessory.context.device = groupDevice;
|
|
201
|
+
this.setupAccessory(existingAccessory, groupDevice);
|
|
202
|
+
} else {
|
|
203
|
+
this.log.info(`Adding new group: ${groupDevice.typeName} (${group.deviceIds.length} members)`);
|
|
204
|
+
this.addAccessory(groupDevice, uuid);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Then add standalone devices (not in any group)
|
|
209
|
+
for (const device of devices) {
|
|
210
|
+
// Skip remote controls - they don't need HomeKit accessories
|
|
211
|
+
if (device.category === protocol.DEVICE_CATEGORY.REMOTE) {
|
|
212
|
+
this.log.debug(`Skipping remote control: ${device.typeName} (0x${device.shortAddress.toString(16)})`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Skip devices that are part of a group - the group handles them
|
|
217
|
+
if (groupedDeviceIds.has(device.shortAddress)) {
|
|
218
|
+
this.log.debug(`Skipping grouped device: ${device.typeName} (0x${device.shortAddress.toString(16)}) - controlled via group`);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Generate unique identifier for this device
|
|
223
|
+
const uuid = this.api.hap.uuid.generate(`smartika-${device.shortAddress}`);
|
|
224
|
+
foundUUIDs.add(uuid);
|
|
225
|
+
|
|
226
|
+
// Check if accessory already exists (from cache)
|
|
227
|
+
const existingAccessory = this.accessories.get(uuid);
|
|
228
|
+
|
|
229
|
+
if (existingAccessory) {
|
|
230
|
+
// Update existing accessory
|
|
231
|
+
this.log.info(`Restoring cached accessory: ${device.typeName} (0x${device.shortAddress.toString(16)})`);
|
|
232
|
+
existingAccessory.context.device = device;
|
|
233
|
+
this.setupAccessory(existingAccessory, device);
|
|
234
|
+
} else {
|
|
235
|
+
// Create new accessory
|
|
236
|
+
this.log.info(`Adding new accessory: ${device.typeName} (0x${device.shortAddress.toString(16)})`);
|
|
237
|
+
this.addAccessory(device, uuid);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Remove accessories that are no longer present
|
|
242
|
+
for (const [uuid, accessory] of this.accessories) {
|
|
243
|
+
if (!foundUUIDs.has(uuid)) {
|
|
244
|
+
this.log.info(`Removing stale accessory: ${accessory.displayName}`);
|
|
245
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
246
|
+
this.accessories.delete(uuid);
|
|
247
|
+
this.deviceHandlers.delete(uuid);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
} catch (error) {
|
|
252
|
+
this.log.error('Failed to discover devices:', error.message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Add a new accessory to Homebridge
|
|
258
|
+
* @param {Object} device - Device info from hub
|
|
259
|
+
* @param {string} uuid - Unique identifier
|
|
260
|
+
*/
|
|
261
|
+
addAccessory(device, uuid) {
|
|
262
|
+
// Determine accessory category based on device type
|
|
263
|
+
let category;
|
|
264
|
+
switch (device.category) {
|
|
265
|
+
case protocol.DEVICE_CATEGORY.LIGHT:
|
|
266
|
+
category = this.api.hap.Categories.LIGHTBULB;
|
|
267
|
+
break;
|
|
268
|
+
case protocol.DEVICE_CATEGORY.FAN:
|
|
269
|
+
category = this.api.hap.Categories.FAN;
|
|
270
|
+
break;
|
|
271
|
+
case protocol.DEVICE_CATEGORY.PLUG:
|
|
272
|
+
category = this.api.hap.Categories.OUTLET;
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
category = this.api.hap.Categories.OTHER;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Create accessory
|
|
279
|
+
const accessory = new this.api.platformAccessory(
|
|
280
|
+
device.typeName,
|
|
281
|
+
uuid,
|
|
282
|
+
category,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Store device info in context for persistence
|
|
286
|
+
accessory.context.device = device;
|
|
287
|
+
|
|
288
|
+
// Configure the accessory
|
|
289
|
+
this.setupAccessory(accessory, device);
|
|
290
|
+
|
|
291
|
+
// Register the accessory
|
|
292
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
293
|
+
this.accessories.set(uuid, accessory);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Handle device status updates from hub
|
|
298
|
+
* @param {Array} devices - Array of device status objects
|
|
299
|
+
*/
|
|
300
|
+
handleDeviceStatusUpdate(devices) {
|
|
301
|
+
this.log.debug(`Received status update for ${devices.length} device(s)`);
|
|
302
|
+
|
|
303
|
+
for (const status of devices) {
|
|
304
|
+
// Try to find handler for regular device
|
|
305
|
+
let uuid = this.api.hap.uuid.generate(`smartika-${status.shortAddress}`);
|
|
306
|
+
let handler = this.deviceHandlers.get(uuid);
|
|
307
|
+
|
|
308
|
+
// If not found, try as a group (groups use 0xFFxx addresses)
|
|
309
|
+
if (!handler && status.shortAddress >= 0xFF00) {
|
|
310
|
+
uuid = this.api.hap.uuid.generate(`smartika-group-${status.shortAddress}`);
|
|
311
|
+
handler = this.deviceHandlers.get(uuid);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (handler) {
|
|
315
|
+
handler.updateStatus(status);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Setup an accessory with appropriate services and handlers
|
|
322
|
+
* @param {import('homebridge').PlatformAccessory} accessory
|
|
323
|
+
* @param {Object} device - Device info
|
|
324
|
+
*/
|
|
325
|
+
setupAccessory(accessory, device) {
|
|
326
|
+
// Get device from context if not provided
|
|
327
|
+
if (!device) {
|
|
328
|
+
device = accessory.context.device;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!device) {
|
|
332
|
+
this.log.warn('No device info available for accessory:', accessory.displayName);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Set up AccessoryInformation service
|
|
337
|
+
const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation) ||
|
|
338
|
+
accessory.addService(this.api.hap.Service.AccessoryInformation);
|
|
339
|
+
|
|
340
|
+
infoService
|
|
341
|
+
.setCharacteristic(this.api.hap.Characteristic.Manufacturer, 'Smartika')
|
|
342
|
+
.setCharacteristic(this.api.hap.Characteristic.Model, device.typeName)
|
|
343
|
+
.setCharacteristic(this.api.hap.Characteristic.SerialNumber, device.macAddress || `0x${device.shortAddress.toString(16)}`);
|
|
344
|
+
|
|
345
|
+
// Create appropriate handler based on device category
|
|
346
|
+
let handler;
|
|
347
|
+
switch (device.category) {
|
|
348
|
+
case protocol.DEVICE_CATEGORY.LIGHT:
|
|
349
|
+
handler = new SmartikaLightAccessory(this, accessory, device);
|
|
350
|
+
break;
|
|
351
|
+
case protocol.DEVICE_CATEGORY.FAN:
|
|
352
|
+
handler = new SmartikaFanAccessory(this, accessory, device);
|
|
353
|
+
break;
|
|
354
|
+
case protocol.DEVICE_CATEGORY.PLUG:
|
|
355
|
+
handler = new SmartikaPlugAccessory(this, accessory, device);
|
|
356
|
+
break;
|
|
357
|
+
default:
|
|
358
|
+
this.log.warn(`Unknown device category: ${device.category} for ${device.typeName}`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Store handler reference
|
|
363
|
+
const uuid = accessory.UUID;
|
|
364
|
+
this.deviceHandlers.set(uuid, handler);
|
|
365
|
+
this.accessories.set(uuid, accessory);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* REQUIRED - Called by Homebridge for each cached accessory on startup
|
|
370
|
+
* @param {import('homebridge').PlatformAccessory} accessory
|
|
371
|
+
*/
|
|
372
|
+
configureAccessory(accessory) {
|
|
373
|
+
this.log.debug('Restoring accessory from cache:', accessory.displayName);
|
|
374
|
+
this.accessories.set(accessory.UUID, accessory);
|
|
375
|
+
// Note: setupAccessory will be called later in discoverDevices with fresh device data
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
module.exports = SmartikaPlatform;
|