homebridge-cync-app 0.1.3 → 0.1.6
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/CHANGELOG.md +44 -10
- package/README.md +30 -22
- package/config.schema.json +2 -7
- package/dist/cync/cync-client.d.ts +8 -7
- package/dist/cync/cync-client.js +44 -19
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +9 -18
- package/dist/cync/tcp-client.js +172 -94
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/platform.d.ts +3 -16
- package/dist/platform.js +349 -43
- package/dist/platform.js.map +1 -1
- package/dist/platformAccessory.js +1 -0
- package/dist/platformAccessory.js.map +1 -1
- package/homebridge-ui/public/icon.png +0 -0
- package/homebridge-ui/public/index.html +171 -110
- package/homebridge-ui/server.js +38 -73
- package/package.json +6 -9
- package/src/cync/cync-client.ts +64 -26
- package/src/cync/tcp-client.ts +281 -123
- package/src/platform.ts +575 -53
- package/src/platformAccessory.ts +2 -0
- package/nodemon.json +0 -12
- package/src/@types/homebridge-lib.d.ts +0 -14
package/src/platform.ts
CHANGED
|
@@ -14,6 +14,23 @@ import type { CyncCloudConfig, CyncDevice, CyncDeviceMesh } from './cync/config-
|
|
|
14
14
|
import { TcpClient } from './cync/tcp-client.js';
|
|
15
15
|
import type { CyncLogger } from './cync/config-client.js';
|
|
16
16
|
|
|
17
|
+
// Narrowed view of the Cync device properties returned by getDeviceProperties()
|
|
18
|
+
type CyncDeviceRaw = {
|
|
19
|
+
displayName?: string;
|
|
20
|
+
firmwareVersion?: string;
|
|
21
|
+
mac?: string;
|
|
22
|
+
wifiMac?: string;
|
|
23
|
+
deviceType?: number;
|
|
24
|
+
deviceID?: number;
|
|
25
|
+
commissionedDate?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// CyncDevice as seen by the platform, possibly enriched with a `raw` block
|
|
30
|
+
type CyncDeviceWithRaw = CyncDevice & {
|
|
31
|
+
raw?: CyncDeviceRaw;
|
|
32
|
+
};
|
|
33
|
+
|
|
17
34
|
const toCyncLogger = (log: Logger): CyncLogger => ({
|
|
18
35
|
debug: log.debug.bind(log),
|
|
19
36
|
info: log.info.bind(log),
|
|
@@ -26,22 +43,87 @@ interface CyncAccessoryContext {
|
|
|
26
43
|
meshId: string;
|
|
27
44
|
deviceId: string;
|
|
28
45
|
productId?: string;
|
|
46
|
+
|
|
29
47
|
on?: boolean;
|
|
48
|
+
brightness?: number; // 0–100 (LAN "level")
|
|
49
|
+
|
|
50
|
+
// Color state (local cache, not yet read from LAN frames)
|
|
51
|
+
hue?: number; // 0–360
|
|
52
|
+
saturation?: number; // 0–100
|
|
53
|
+
rgb?: { r: number; g: number; b: number };
|
|
54
|
+
colorActive?: boolean; // true if we last set RGB color
|
|
30
55
|
};
|
|
31
56
|
[key: string]: unknown;
|
|
32
57
|
}
|
|
33
58
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
function hsvToRgb(hue: number, saturation: number, value: number): { r: number; g: number; b: number } {
|
|
60
|
+
const h = ((hue % 360) + 360) % 360;
|
|
61
|
+
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
|
62
|
+
const v = Math.max(0, Math.min(100, value)) / 100;
|
|
63
|
+
|
|
64
|
+
if (s === 0) {
|
|
65
|
+
const grey = Math.round(v * 255);
|
|
66
|
+
return { r: grey, g: grey, b: grey };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sector = h / 60;
|
|
70
|
+
const i = Math.floor(sector);
|
|
71
|
+
const f = sector - i;
|
|
72
|
+
|
|
73
|
+
const p = v * (1 - s);
|
|
74
|
+
const q = v * (1 - s * f);
|
|
75
|
+
const t = v * (1 - s * (1 - f));
|
|
76
|
+
|
|
77
|
+
let r = 0;
|
|
78
|
+
let g = 0;
|
|
79
|
+
let b = 0;
|
|
80
|
+
|
|
81
|
+
switch (i) {
|
|
82
|
+
case 0:
|
|
83
|
+
r = v;
|
|
84
|
+
g = t;
|
|
85
|
+
b = p;
|
|
86
|
+
break;
|
|
87
|
+
case 1:
|
|
88
|
+
r = q;
|
|
89
|
+
g = v;
|
|
90
|
+
b = p;
|
|
91
|
+
break;
|
|
92
|
+
case 2:
|
|
93
|
+
r = p;
|
|
94
|
+
g = v;
|
|
95
|
+
b = t;
|
|
96
|
+
break;
|
|
97
|
+
case 3:
|
|
98
|
+
r = p;
|
|
99
|
+
g = q;
|
|
100
|
+
b = v;
|
|
101
|
+
break;
|
|
102
|
+
case 4:
|
|
103
|
+
r = t;
|
|
104
|
+
g = p;
|
|
105
|
+
b = v;
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
r = v;
|
|
109
|
+
g = p;
|
|
110
|
+
b = q;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
r: Math.round(r * 255),
|
|
116
|
+
g: Math.round(g * 255),
|
|
117
|
+
b: Math.round(b * 255),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
42
121
|
export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
43
122
|
public readonly accessories: PlatformAccessory[] = [];
|
|
44
|
-
|
|
123
|
+
public configureAccessory(accessory: PlatformAccessory): void {
|
|
124
|
+
this.log.info('Restoring cached accessory', accessory.displayName);
|
|
125
|
+
this.accessories.push(accessory);
|
|
126
|
+
}
|
|
45
127
|
private readonly log: Logger;
|
|
46
128
|
private readonly api: API;
|
|
47
129
|
private readonly config: PlatformConfig;
|
|
@@ -51,11 +133,15 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
51
133
|
private cloudConfig: CyncCloudConfig | null = null;
|
|
52
134
|
private readonly deviceIdToAccessory = new Map<string, PlatformAccessory>();
|
|
53
135
|
private handleLanUpdate(update: unknown): void {
|
|
54
|
-
//
|
|
55
|
-
// { controllerId: number, on: boolean, level: number
|
|
56
|
-
const payload = update as {
|
|
136
|
+
// Parsed 0x83 frames from TcpClient.parseLanSwitchUpdate look like:
|
|
137
|
+
// { controllerId: number, deviceId?: string, on: boolean, level: number }
|
|
138
|
+
const payload = update as {
|
|
139
|
+
deviceId?: string;
|
|
140
|
+
on?: boolean;
|
|
141
|
+
level?: number;
|
|
142
|
+
};
|
|
57
143
|
|
|
58
|
-
if (!payload || typeof payload.deviceId !== 'string'
|
|
144
|
+
if (!payload || typeof payload.deviceId !== 'string') {
|
|
59
145
|
return;
|
|
60
146
|
}
|
|
61
147
|
|
|
@@ -68,10 +154,16 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
68
154
|
return;
|
|
69
155
|
}
|
|
70
156
|
|
|
71
|
-
const
|
|
72
|
-
|
|
157
|
+
const Service = this.api.hap.Service;
|
|
158
|
+
const Characteristic = this.api.hap.Characteristic;
|
|
159
|
+
|
|
160
|
+
const lightService = accessory.getService(Service.Lightbulb);
|
|
161
|
+
const switchService = accessory.getService(Service.Switch);
|
|
162
|
+
const primaryService = lightService || switchService;
|
|
163
|
+
|
|
164
|
+
if (!primaryService) {
|
|
73
165
|
this.log.debug(
|
|
74
|
-
'Cync: accessory %s has no Switch service for deviceId=%s',
|
|
166
|
+
'Cync: accessory %s has no Lightbulb or Switch service for deviceId=%s',
|
|
75
167
|
accessory.displayName,
|
|
76
168
|
payload.deviceId,
|
|
77
169
|
);
|
|
@@ -84,17 +176,44 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
84
176
|
meshId: '',
|
|
85
177
|
deviceId: payload.deviceId,
|
|
86
178
|
};
|
|
87
|
-
ctx.cync.on = payload.on;
|
|
88
179
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
180
|
+
// ----- On/off -----
|
|
181
|
+
if (typeof payload.on === 'boolean') {
|
|
182
|
+
ctx.cync.on = payload.on;
|
|
183
|
+
|
|
184
|
+
this.log.info(
|
|
185
|
+
'Cync: LAN update -> %s is now %s (deviceId=%s)',
|
|
186
|
+
accessory.displayName,
|
|
187
|
+
payload.on ? 'ON' : 'OFF',
|
|
188
|
+
payload.deviceId,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
primaryService.updateCharacteristic(Characteristic.On, payload.on);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ----- Brightness (LAN "level" 0–100) -----
|
|
195
|
+
if (typeof payload.level === 'number' && lightService) {
|
|
196
|
+
const brightness = Math.max(
|
|
197
|
+
0,
|
|
198
|
+
Math.min(100, Math.round(payload.level)),
|
|
199
|
+
);
|
|
95
200
|
|
|
96
|
-
|
|
97
|
-
|
|
201
|
+
ctx.cync.brightness = brightness;
|
|
202
|
+
|
|
203
|
+
this.log.debug(
|
|
204
|
+
'Cync: LAN update -> %s brightness=%d (deviceId=%s)',
|
|
205
|
+
accessory.displayName,
|
|
206
|
+
brightness,
|
|
207
|
+
payload.deviceId,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (lightService.testCharacteristic(Characteristic.Brightness)) {
|
|
211
|
+
lightService.updateCharacteristic(
|
|
212
|
+
Characteristic.Brightness,
|
|
213
|
+
brightness,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
98
217
|
}
|
|
99
218
|
|
|
100
219
|
private configureCyncSwitchAccessory(
|
|
@@ -107,7 +226,15 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
107
226
|
const service =
|
|
108
227
|
accessory.getService(this.api.hap.Service.Switch) ||
|
|
109
228
|
accessory.addService(this.api.hap.Service.Switch, deviceName);
|
|
110
|
-
|
|
229
|
+
const existingLight = accessory.getService(this.api.hap.Service.Lightbulb);
|
|
230
|
+
if (existingLight) {
|
|
231
|
+
this.log.info(
|
|
232
|
+
'Cync: removing stale Lightbulb service from %s (deviceId=%s) before configuring as Switch',
|
|
233
|
+
deviceName,
|
|
234
|
+
deviceId,
|
|
235
|
+
);
|
|
236
|
+
accessory.removeService(existingLight);
|
|
237
|
+
}
|
|
111
238
|
// Ensure context is initialized
|
|
112
239
|
const ctx = accessory.context as CyncAccessoryContext;
|
|
113
240
|
ctx.cync = ctx.cync ?? {
|
|
@@ -137,7 +264,103 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
137
264
|
|
|
138
265
|
if (!cyncMeta?.deviceId) {
|
|
139
266
|
this.log.warn(
|
|
140
|
-
'Cync: On.set called for %s but no cync.deviceId in context',
|
|
267
|
+
'Cync: Light On.set called for %s but no cync.deviceId in context',
|
|
268
|
+
deviceName,
|
|
269
|
+
);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const on = value === true || value === 1;
|
|
274
|
+
|
|
275
|
+
this.log.info(
|
|
276
|
+
'Cync: Light On.set -> %s for %s (deviceId=%s)',
|
|
277
|
+
String(on),
|
|
278
|
+
deviceName,
|
|
279
|
+
cyncMeta.deviceId,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
cyncMeta.on = on;
|
|
283
|
+
|
|
284
|
+
if (!on) {
|
|
285
|
+
// Off is always a plain power packet
|
|
286
|
+
await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on: false });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Turning on:
|
|
291
|
+
// - If we were in color mode with a known RGB + brightness, restore color.
|
|
292
|
+
// - Otherwise, just send a basic power-on packet.
|
|
293
|
+
if (cyncMeta.colorActive && cyncMeta.rgb && typeof cyncMeta.brightness === 'number') {
|
|
294
|
+
await this.tcpClient.setColor(cyncMeta.deviceId, cyncMeta.rgb, cyncMeta.brightness);
|
|
295
|
+
} else {
|
|
296
|
+
await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on: true });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private configureCyncLightAccessory(
|
|
302
|
+
mesh: CyncDeviceMesh,
|
|
303
|
+
device: CyncDevice,
|
|
304
|
+
accessory: PlatformAccessory,
|
|
305
|
+
deviceName: string,
|
|
306
|
+
deviceId: string,
|
|
307
|
+
): void {
|
|
308
|
+
// If this accessory used to be a switch, remove that service
|
|
309
|
+
const existingSwitch = accessory.getService(this.api.hap.Service.Switch);
|
|
310
|
+
if (existingSwitch) {
|
|
311
|
+
this.log.info(
|
|
312
|
+
'Cync: removing stale Switch service from %s (deviceId=%s) before configuring as Lightbulb',
|
|
313
|
+
deviceName,
|
|
314
|
+
deviceId,
|
|
315
|
+
);
|
|
316
|
+
accessory.removeService(existingSwitch);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const service =
|
|
320
|
+
accessory.getService(this.api.hap.Service.Lightbulb) ||
|
|
321
|
+
accessory.addService(this.api.hap.Service.Lightbulb, deviceName);
|
|
322
|
+
|
|
323
|
+
// Optionally update accessory category so UIs treat it as a light
|
|
324
|
+
if (accessory.category !== this.api.hap.Categories.LIGHTBULB) {
|
|
325
|
+
accessory.category = this.api.hap.Categories.LIGHTBULB;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// NEW: populate Accessory Information from Cync metadata
|
|
329
|
+
this.applyAccessoryInformationFromCyncDevice(accessory, device, deviceName, deviceId);
|
|
330
|
+
|
|
331
|
+
// Ensure context is initialized
|
|
332
|
+
const ctx = accessory.context as CyncAccessoryContext;
|
|
333
|
+
ctx.cync = ctx.cync ?? {
|
|
334
|
+
meshId: mesh.id,
|
|
335
|
+
deviceId,
|
|
336
|
+
productId: device.product_id,
|
|
337
|
+
on: false,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Remember mapping for LAN updates
|
|
341
|
+
this.deviceIdToAccessory.set(deviceId, accessory);
|
|
342
|
+
|
|
343
|
+
const Characteristic = this.api.hap.Characteristic;
|
|
344
|
+
|
|
345
|
+
// ----- On/Off -----
|
|
346
|
+
service
|
|
347
|
+
.getCharacteristic(Characteristic.On)
|
|
348
|
+
.onGet(() => {
|
|
349
|
+
const currentOn = !!ctx.cync?.on;
|
|
350
|
+
this.log.info(
|
|
351
|
+
'Cync: Light On.get -> %s for %s (deviceId=%s)',
|
|
352
|
+
String(currentOn),
|
|
353
|
+
deviceName,
|
|
354
|
+
deviceId,
|
|
355
|
+
);
|
|
356
|
+
return currentOn;
|
|
357
|
+
})
|
|
358
|
+
.onSet(async (value) => {
|
|
359
|
+
const cyncMeta = ctx.cync;
|
|
360
|
+
|
|
361
|
+
if (!cyncMeta?.deviceId) {
|
|
362
|
+
this.log.warn(
|
|
363
|
+
'Cync: Light On.set called for %s but no cync.deviceId in context',
|
|
141
364
|
deviceName,
|
|
142
365
|
);
|
|
143
366
|
return;
|
|
@@ -146,7 +369,7 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
146
369
|
const on = value === true || value === 1;
|
|
147
370
|
|
|
148
371
|
this.log.info(
|
|
149
|
-
'Cync: On.set -> %s for %s (deviceId=%s)',
|
|
372
|
+
'Cync: Light On.set -> %s for %s (deviceId=%s)',
|
|
150
373
|
String(on),
|
|
151
374
|
deviceName,
|
|
152
375
|
cyncMeta.deviceId,
|
|
@@ -157,6 +380,254 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
157
380
|
|
|
158
381
|
await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on });
|
|
159
382
|
});
|
|
383
|
+
|
|
384
|
+
// ----- Brightness (dimming via LAN combo_control) -----
|
|
385
|
+
service
|
|
386
|
+
.getCharacteristic(Characteristic.Brightness)
|
|
387
|
+
.onGet(() => {
|
|
388
|
+
const current = ctx.cync?.brightness;
|
|
389
|
+
|
|
390
|
+
// If we have a cached LAN level, use it; otherwise infer from On.
|
|
391
|
+
if (typeof current === 'number') {
|
|
392
|
+
return current;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const on = ctx.cync?.on ?? false;
|
|
396
|
+
return on ? 100 : 0;
|
|
397
|
+
})
|
|
398
|
+
.onSet(async (value) => {
|
|
399
|
+
const cyncMeta = ctx.cync;
|
|
400
|
+
|
|
401
|
+
if (!cyncMeta?.deviceId) {
|
|
402
|
+
this.log.warn(
|
|
403
|
+
'Cync: Light Brightness.set called for %s but no cync.deviceId in context',
|
|
404
|
+
deviceName,
|
|
405
|
+
);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const brightness = Math.max(
|
|
410
|
+
0,
|
|
411
|
+
Math.min(100, Number(value)),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
if (!Number.isFinite(brightness)) {
|
|
415
|
+
this.log.warn(
|
|
416
|
+
'Cync: Light Brightness.set received invalid value=%o for %s (deviceId=%s)',
|
|
417
|
+
value,
|
|
418
|
+
deviceName,
|
|
419
|
+
cyncMeta.deviceId,
|
|
420
|
+
);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Optimistic cache
|
|
425
|
+
cyncMeta.brightness = brightness;
|
|
426
|
+
cyncMeta.on = brightness > 0;
|
|
427
|
+
|
|
428
|
+
this.log.info(
|
|
429
|
+
'Cync: Light Brightness.set -> %d for %s (deviceId=%s)',
|
|
430
|
+
brightness,
|
|
431
|
+
deviceName,
|
|
432
|
+
cyncMeta.deviceId,
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// If we're in "color mode", keep the existing RGB and scale brightness via setColor();
|
|
436
|
+
// otherwise treat this as a white-brightness change.
|
|
437
|
+
if (cyncMeta.colorActive && cyncMeta.rgb) {
|
|
438
|
+
await this.tcpClient.setColor(cyncMeta.deviceId, cyncMeta.rgb, brightness);
|
|
439
|
+
} else {
|
|
440
|
+
await this.tcpClient.setBrightness(cyncMeta.deviceId, brightness);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
// ----- Hue -----
|
|
444
|
+
service
|
|
445
|
+
.getCharacteristic(Characteristic.Hue)
|
|
446
|
+
.onGet(() => {
|
|
447
|
+
const hue = ctx.cync?.hue;
|
|
448
|
+
if (typeof hue === 'number') {
|
|
449
|
+
return hue;
|
|
450
|
+
}
|
|
451
|
+
// Default to 0° (red) if we have no color history
|
|
452
|
+
return 0;
|
|
453
|
+
})
|
|
454
|
+
.onSet(async (value) => {
|
|
455
|
+
const cyncMeta = ctx.cync;
|
|
456
|
+
|
|
457
|
+
if (!cyncMeta?.deviceId) {
|
|
458
|
+
this.log.warn(
|
|
459
|
+
'Cync: Light Hue.set called for %s but no cync.deviceId in context',
|
|
460
|
+
deviceName,
|
|
461
|
+
);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const hue = Math.max(0, Math.min(360, Number(value)));
|
|
466
|
+
if (!Number.isFinite(hue)) {
|
|
467
|
+
this.log.warn(
|
|
468
|
+
'Cync: Light Hue.set received invalid value=%o for %s (deviceId=%s)',
|
|
469
|
+
value,
|
|
470
|
+
deviceName,
|
|
471
|
+
cyncMeta.deviceId,
|
|
472
|
+
);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Use cached saturation/brightness if available, otherwise sane defaults
|
|
477
|
+
const saturation = typeof cyncMeta.saturation === 'number'
|
|
478
|
+
? cyncMeta.saturation
|
|
479
|
+
: 100;
|
|
480
|
+
|
|
481
|
+
const brightness = typeof cyncMeta.brightness === 'number'
|
|
482
|
+
? cyncMeta.brightness
|
|
483
|
+
: 100;
|
|
484
|
+
|
|
485
|
+
const rgb = hsvToRgb(hue, saturation, brightness);
|
|
486
|
+
|
|
487
|
+
// Optimistic cache
|
|
488
|
+
cyncMeta.hue = hue;
|
|
489
|
+
cyncMeta.saturation = saturation;
|
|
490
|
+
cyncMeta.rgb = rgb;
|
|
491
|
+
cyncMeta.colorActive = true;
|
|
492
|
+
cyncMeta.on = brightness > 0;
|
|
493
|
+
cyncMeta.brightness = brightness;
|
|
494
|
+
|
|
495
|
+
this.log.info(
|
|
496
|
+
'Cync: Light Hue.set -> %d for %s (deviceId=%s) -> rgb=(%d,%d,%d) brightness=%d',
|
|
497
|
+
hue,
|
|
498
|
+
deviceName,
|
|
499
|
+
cyncMeta.deviceId,
|
|
500
|
+
rgb.r,
|
|
501
|
+
rgb.g,
|
|
502
|
+
rgb.b,
|
|
503
|
+
brightness,
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
await this.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ----- Saturation -----
|
|
510
|
+
service
|
|
511
|
+
.getCharacteristic(Characteristic.Saturation)
|
|
512
|
+
.onGet(() => {
|
|
513
|
+
const sat = ctx.cync?.saturation;
|
|
514
|
+
if (typeof sat === 'number') {
|
|
515
|
+
return sat;
|
|
516
|
+
}
|
|
517
|
+
return 100;
|
|
518
|
+
})
|
|
519
|
+
.onSet(async (value) => {
|
|
520
|
+
const cyncMeta = ctx.cync;
|
|
521
|
+
|
|
522
|
+
if (!cyncMeta?.deviceId) {
|
|
523
|
+
this.log.warn(
|
|
524
|
+
'Cync: Light Saturation.set called for %s but no cync.deviceId in context',
|
|
525
|
+
deviceName,
|
|
526
|
+
);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const saturation = Math.max(0, Math.min(100, Number(value)));
|
|
531
|
+
if (!Number.isFinite(saturation)) {
|
|
532
|
+
this.log.warn(
|
|
533
|
+
'Cync: Light Saturation.set received invalid value=%o for %s (deviceId=%s)',
|
|
534
|
+
value,
|
|
535
|
+
deviceName,
|
|
536
|
+
cyncMeta.deviceId,
|
|
537
|
+
);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const hue = typeof cyncMeta.hue === 'number'
|
|
542
|
+
? cyncMeta.hue
|
|
543
|
+
: 0;
|
|
544
|
+
|
|
545
|
+
const brightness = typeof cyncMeta.brightness === 'number'
|
|
546
|
+
? cyncMeta.brightness
|
|
547
|
+
: 100;
|
|
548
|
+
|
|
549
|
+
const rgb = hsvToRgb(hue, saturation, brightness);
|
|
550
|
+
|
|
551
|
+
// Optimistic cache
|
|
552
|
+
cyncMeta.hue = hue;
|
|
553
|
+
cyncMeta.saturation = saturation;
|
|
554
|
+
cyncMeta.rgb = rgb;
|
|
555
|
+
cyncMeta.colorActive = true;
|
|
556
|
+
cyncMeta.on = brightness > 0;
|
|
557
|
+
cyncMeta.brightness = brightness;
|
|
558
|
+
|
|
559
|
+
this.log.info(
|
|
560
|
+
'Cync: Light Saturation.set -> %d for %s (deviceId=%s) -> rgb=(%d,%d,%d) brightness=%d',
|
|
561
|
+
saturation,
|
|
562
|
+
deviceName,
|
|
563
|
+
cyncMeta.deviceId,
|
|
564
|
+
rgb.r,
|
|
565
|
+
rgb.g,
|
|
566
|
+
rgb.b,
|
|
567
|
+
brightness,
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
await this.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private applyAccessoryInformationFromCyncDevice(
|
|
575
|
+
accessory: PlatformAccessory,
|
|
576
|
+
device: CyncDevice,
|
|
577
|
+
deviceName: string,
|
|
578
|
+
deviceId: string,
|
|
579
|
+
): void {
|
|
580
|
+
const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation);
|
|
581
|
+
if (!infoService) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const Characteristic = this.api.hap.Characteristic;
|
|
586
|
+
const deviceWithRaw = device as CyncDeviceWithRaw;
|
|
587
|
+
const rawDevice = deviceWithRaw.raw ?? {};
|
|
588
|
+
|
|
589
|
+
// Name: keep in sync with how we present the accessory
|
|
590
|
+
const name = deviceName || accessory.displayName;
|
|
591
|
+
infoService.updateCharacteristic(Characteristic.Name, name);
|
|
592
|
+
|
|
593
|
+
// Manufacturer: fixed for all Cync devices
|
|
594
|
+
infoService.updateCharacteristic(Characteristic.Manufacturer, 'GE Lighting');
|
|
595
|
+
|
|
596
|
+
// Model: use the device's displayName + type if available
|
|
597
|
+
const modelBase =
|
|
598
|
+
typeof rawDevice.displayName === 'string' && rawDevice.displayName.trim().length > 0
|
|
599
|
+
? rawDevice.displayName.trim()
|
|
600
|
+
: 'Cync Device';
|
|
601
|
+
|
|
602
|
+
const modelSuffix =
|
|
603
|
+
typeof rawDevice.deviceType === 'number'
|
|
604
|
+
? ` (Type ${rawDevice.deviceType})`
|
|
605
|
+
: '';
|
|
606
|
+
|
|
607
|
+
infoService.updateCharacteristic(
|
|
608
|
+
Characteristic.Model,
|
|
609
|
+
modelBase + modelSuffix,
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
// Serial: prefer wifiMac, then mac, then deviceID, then the string deviceId
|
|
613
|
+
const serial =
|
|
614
|
+
(typeof rawDevice.wifiMac === 'string' && rawDevice.wifiMac.trim().length > 0)
|
|
615
|
+
? rawDevice.wifiMac.trim()
|
|
616
|
+
: (typeof rawDevice.mac === 'string' && rawDevice.mac.trim().length > 0)
|
|
617
|
+
? rawDevice.mac.trim()
|
|
618
|
+
: (rawDevice.deviceID !== undefined
|
|
619
|
+
? String(rawDevice.deviceID)
|
|
620
|
+
: deviceId);
|
|
621
|
+
|
|
622
|
+
infoService.updateCharacteristic(Characteristic.SerialNumber, serial);
|
|
623
|
+
|
|
624
|
+
// Firmware revision, if present
|
|
625
|
+
if (typeof rawDevice.firmwareVersion === 'string' && rawDevice.firmwareVersion.trim().length > 0) {
|
|
626
|
+
infoService.updateCharacteristic(
|
|
627
|
+
Characteristic.FirmwareRevision,
|
|
628
|
+
rawDevice.firmwareVersion.trim(),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
160
631
|
}
|
|
161
632
|
|
|
162
633
|
constructor(log: Logger, config: PlatformConfig, api: API) {
|
|
@@ -165,10 +636,25 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
165
636
|
this.api = api;
|
|
166
637
|
|
|
167
638
|
// Extract login config from platform config
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
639
|
+
const raw = this.config as Record<string, unknown>;
|
|
640
|
+
|
|
641
|
+
// Canonical config keys: username, password, twoFactor
|
|
642
|
+
const username =
|
|
643
|
+
typeof raw.username === 'string'
|
|
644
|
+
? raw.username
|
|
645
|
+
: typeof raw.email === 'string'
|
|
646
|
+
? raw.email
|
|
647
|
+
: '';
|
|
648
|
+
|
|
649
|
+
const password =
|
|
650
|
+
typeof raw.password === 'string'
|
|
651
|
+
? raw.password
|
|
652
|
+
: '';
|
|
653
|
+
|
|
654
|
+
const twoFactor =
|
|
655
|
+
typeof raw.twoFactor === 'string'
|
|
656
|
+
? raw.twoFactor
|
|
657
|
+
: undefined;
|
|
172
658
|
|
|
173
659
|
const cyncLogger = toCyncLogger(this.log);
|
|
174
660
|
const tcpClient = new TcpClient(cyncLogger);
|
|
@@ -177,8 +663,8 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
177
663
|
new ConfigClient(cyncLogger),
|
|
178
664
|
tcpClient,
|
|
179
665
|
{
|
|
180
|
-
|
|
181
|
-
password
|
|
666
|
+
username,
|
|
667
|
+
password,
|
|
182
668
|
twoFactor,
|
|
183
669
|
},
|
|
184
670
|
this.api.user.storagePath(),
|
|
@@ -200,19 +686,21 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
200
686
|
});
|
|
201
687
|
}
|
|
202
688
|
|
|
203
|
-
/**
|
|
204
|
-
* Called when cached accessories are restored from disk.
|
|
205
|
-
*/
|
|
206
|
-
configureAccessory(accessory: PlatformAccessory): void {
|
|
207
|
-
this.log.info('Restoring cached accessory', accessory.displayName);
|
|
208
|
-
this.accessories.push(accessory);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
689
|
private async loadCync(): Promise<void> {
|
|
212
690
|
try {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
const
|
|
691
|
+
const raw = this.config as Record<string, unknown>;
|
|
692
|
+
|
|
693
|
+
const username =
|
|
694
|
+
typeof raw.username === 'string'
|
|
695
|
+
? raw.username
|
|
696
|
+
: typeof raw.email === 'string'
|
|
697
|
+
? raw.email
|
|
698
|
+
: '';
|
|
699
|
+
|
|
700
|
+
const password =
|
|
701
|
+
typeof raw.password === 'string'
|
|
702
|
+
? raw.password
|
|
703
|
+
: '';
|
|
216
704
|
|
|
217
705
|
if (!username || !password) {
|
|
218
706
|
this.log.warn('Cync: credentials missing in config.json; skipping cloud login.');
|
|
@@ -237,7 +725,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
237
725
|
);
|
|
238
726
|
|
|
239
727
|
// Ask the CyncClient for the LAN login code derived from stored session.
|
|
240
|
-
// If it returns an empty blob, LAN is disabled but cloud still works.
|
|
241
728
|
let loginCode: Uint8Array = new Uint8Array();
|
|
242
729
|
try {
|
|
243
730
|
loginCode = this.client.getLanLoginCode();
|
|
@@ -254,7 +741,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
254
741
|
loginCode.length,
|
|
255
742
|
);
|
|
256
743
|
|
|
257
|
-
// ### 🧩 LAN Transport Bootstrap: wire frame listeners via CyncClient
|
|
258
744
|
await this.client.startTransport(cloudConfig, loginCode);
|
|
259
745
|
} else {
|
|
260
746
|
this.log.info(
|
|
@@ -272,10 +758,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
272
758
|
}
|
|
273
759
|
}
|
|
274
760
|
|
|
275
|
-
/**
|
|
276
|
-
* Discover devices from the Cync cloud config and register them as
|
|
277
|
-
* Homebridge accessories.
|
|
278
|
-
*/
|
|
279
761
|
private discoverDevices(cloudConfig: CyncCloudConfig): void {
|
|
280
762
|
if (!cloudConfig.meshes?.length) {
|
|
281
763
|
this.log.warn('Cync: no meshes returned from cloud; nothing to discover.');
|
|
@@ -327,9 +809,49 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
327
809
|
this.accessories.push(accessory);
|
|
328
810
|
}
|
|
329
811
|
|
|
330
|
-
this
|
|
812
|
+
// Decide how to expose this device in HomeKit based on device_type / raw.deviceType
|
|
813
|
+
const typedDevice = device as unknown as {
|
|
814
|
+
device_type?: number;
|
|
815
|
+
raw?: { deviceType?: number | string };
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
let deviceType: number | undefined;
|
|
819
|
+
|
|
820
|
+
if (typeof typedDevice.device_type === 'number') {
|
|
821
|
+
deviceType = typedDevice.device_type;
|
|
822
|
+
} else if (typedDevice.raw && typeof typedDevice.raw.deviceType === 'number') {
|
|
823
|
+
deviceType = typedDevice.raw.deviceType;
|
|
824
|
+
} else if (
|
|
825
|
+
typedDevice.raw &&
|
|
826
|
+
typeof typedDevice.raw.deviceType === 'string' &&
|
|
827
|
+
typedDevice.raw.deviceType.trim() !== ''
|
|
828
|
+
) {
|
|
829
|
+
const parsed = Number(typedDevice.raw.deviceType);
|
|
830
|
+
if (!Number.isNaN(parsed)) {
|
|
831
|
+
deviceType = parsed;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const isDownlight = deviceType === 46;
|
|
836
|
+
|
|
837
|
+
if (isDownlight) {
|
|
838
|
+
this.log.info(
|
|
839
|
+
'Cync: configuring %s as Lightbulb (deviceType=%s, deviceId=%s)',
|
|
840
|
+
deviceName,
|
|
841
|
+
String(deviceType),
|
|
842
|
+
deviceId,
|
|
843
|
+
);
|
|
844
|
+
this.configureCyncLightAccessory(mesh, device, accessory, deviceName, deviceId);
|
|
845
|
+
} else {
|
|
846
|
+
this.log.info(
|
|
847
|
+
'Cync: configuring %s as Switch (deviceType=%s, deviceId=%s)',
|
|
848
|
+
deviceName,
|
|
849
|
+
deviceType ?? 'unknown',
|
|
850
|
+
deviceId,
|
|
851
|
+
);
|
|
852
|
+
this.configureCyncSwitchAccessory(mesh, device, accessory, deviceName, deviceId);
|
|
853
|
+
}
|
|
331
854
|
}
|
|
332
855
|
}
|
|
333
856
|
}
|
|
334
|
-
|
|
335
857
|
}
|