homebridge-cync-app 0.1.7 → 0.1.9
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 +13 -0
- package/dist/cync/config-client.d.ts +11 -0
- package/dist/cync/config-client.js +113 -6
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-accessory-helpers.d.ts +46 -0
- package/dist/cync/cync-accessory-helpers.js +140 -0
- package/dist/cync/cync-accessory-helpers.js.map +1 -0
- package/dist/cync/cync-client.d.ts +4 -0
- package/dist/cync/cync-client.js +150 -34
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/cync-light-accessory.d.ts +4 -0
- package/dist/cync/cync-light-accessory.js +190 -0
- package/dist/cync/cync-light-accessory.js.map +1 -0
- package/dist/cync/cync-switch-accessory.d.ts +4 -0
- package/dist/cync/cync-switch-accessory.js +64 -0
- package/dist/cync/cync-switch-accessory.js.map +1 -0
- package/dist/cync/device-catalog.js +9 -4
- package/dist/cync/device-catalog.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +7 -0
- package/dist/cync/tcp-client.js +122 -30
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.js +2 -2
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +1 -3
- package/dist/platform.js +18 -382
- package/dist/platform.js.map +1 -1
- package/package.json +1 -1
- package/src/cync/config-client.ts +175 -12
- package/src/cync/cync-accessory-helpers.ts +233 -0
- package/src/cync/cync-client.ts +231 -44
- package/src/cync/cync-light-accessory.ts +369 -0
- package/src/cync/cync-switch-accessory.ts +119 -0
- package/src/cync/device-catalog.ts +9 -4
- package/src/cync/tcp-client.ts +153 -53
- package/src/cync/token-store.ts +3 -2
- package/src/platform.ts +49 -661
package/src/platform.ts
CHANGED
|
@@ -10,27 +10,16 @@ import type {
|
|
|
10
10
|
import { PLATFORM_NAME } from './settings.js';
|
|
11
11
|
import { CyncClient } from './cync/cync-client.js';
|
|
12
12
|
import { ConfigClient } from './cync/config-client.js';
|
|
13
|
-
import type { CyncCloudConfig
|
|
13
|
+
import type { CyncCloudConfig } from './cync/config-client.js';
|
|
14
14
|
import { TcpClient } from './cync/tcp-client.js';
|
|
15
15
|
import type { CyncLogger } from './cync/config-client.js';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
wifiMac?: string;
|
|
24
|
-
deviceType?: number;
|
|
25
|
-
deviceID?: number;
|
|
26
|
-
commissionedDate?: string;
|
|
27
|
-
[key: string]: unknown;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// CyncDevice as seen by the platform, possibly enriched with a `raw` block
|
|
31
|
-
type CyncDeviceWithRaw = CyncDevice & {
|
|
32
|
-
raw?: CyncDeviceRaw;
|
|
33
|
-
};
|
|
16
|
+
import {
|
|
17
|
+
type CyncAccessoryContext,
|
|
18
|
+
type CyncAccessoryEnv,
|
|
19
|
+
resolveDeviceType,
|
|
20
|
+
} from './cync/cync-accessory-helpers.js';
|
|
21
|
+
import { configureCyncLightAccessory } from './cync/cync-light-accessory.js';
|
|
22
|
+
import { configureCyncSwitchAccessory } from './cync/cync-switch-accessory.js';
|
|
34
23
|
|
|
35
24
|
const toCyncLogger = (log: Logger): CyncLogger => ({
|
|
36
25
|
debug: log.debug.bind(log),
|
|
@@ -39,111 +28,6 @@ const toCyncLogger = (log: Logger): CyncLogger => ({
|
|
|
39
28
|
error: log.error.bind(log),
|
|
40
29
|
});
|
|
41
30
|
|
|
42
|
-
interface CyncAccessoryContext {
|
|
43
|
-
cync?: {
|
|
44
|
-
meshId: string;
|
|
45
|
-
deviceId: string;
|
|
46
|
-
productId?: string;
|
|
47
|
-
|
|
48
|
-
on?: boolean;
|
|
49
|
-
brightness?: number; // 0–100 (LAN "level")
|
|
50
|
-
|
|
51
|
-
// Color state (local cache, not yet read from LAN frames)
|
|
52
|
-
hue?: number; // 0–360
|
|
53
|
-
saturation?: number; // 0–100
|
|
54
|
-
rgb?: { r: number; g: number; b: number };
|
|
55
|
-
colorActive?: boolean; // true if we last set RGB color
|
|
56
|
-
};
|
|
57
|
-
[key: string]: unknown;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function hsvToRgb(hue: number, saturation: number, value: number): { r: number; g: number; b: number } {
|
|
61
|
-
const h = ((hue % 360) + 360) % 360;
|
|
62
|
-
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
|
63
|
-
const v = Math.max(0, Math.min(100, value)) / 100;
|
|
64
|
-
|
|
65
|
-
if (s === 0) {
|
|
66
|
-
const grey = Math.round(v * 255);
|
|
67
|
-
return { r: grey, g: grey, b: grey };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const sector = h / 60;
|
|
71
|
-
const i = Math.floor(sector);
|
|
72
|
-
const f = sector - i;
|
|
73
|
-
|
|
74
|
-
const p = v * (1 - s);
|
|
75
|
-
const q = v * (1 - s * f);
|
|
76
|
-
const t = v * (1 - s * (1 - f));
|
|
77
|
-
|
|
78
|
-
let r = 0;
|
|
79
|
-
let g = 0;
|
|
80
|
-
let b = 0;
|
|
81
|
-
|
|
82
|
-
switch (i) {
|
|
83
|
-
case 0:
|
|
84
|
-
r = v;
|
|
85
|
-
g = t;
|
|
86
|
-
b = p;
|
|
87
|
-
break;
|
|
88
|
-
case 1:
|
|
89
|
-
r = q;
|
|
90
|
-
g = v;
|
|
91
|
-
b = p;
|
|
92
|
-
break;
|
|
93
|
-
case 2:
|
|
94
|
-
r = p;
|
|
95
|
-
g = v;
|
|
96
|
-
b = t;
|
|
97
|
-
break;
|
|
98
|
-
case 3:
|
|
99
|
-
r = p;
|
|
100
|
-
g = q;
|
|
101
|
-
b = v;
|
|
102
|
-
break;
|
|
103
|
-
case 4:
|
|
104
|
-
r = t;
|
|
105
|
-
g = p;
|
|
106
|
-
b = v;
|
|
107
|
-
break;
|
|
108
|
-
default:
|
|
109
|
-
r = v;
|
|
110
|
-
g = p;
|
|
111
|
-
b = q;
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
r: Math.round(r * 255),
|
|
117
|
-
g: Math.round(g * 255),
|
|
118
|
-
b: Math.round(b * 255),
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function resolveDeviceType(device: CyncDevice): number | undefined {
|
|
123
|
-
const typedDevice = device as unknown as {
|
|
124
|
-
device_type?: number;
|
|
125
|
-
raw?: { deviceType?: number | string };
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
if (typeof typedDevice.device_type === 'number') {
|
|
129
|
-
return typedDevice.device_type;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const rawType = typedDevice.raw?.deviceType;
|
|
133
|
-
if (typeof rawType === 'number') {
|
|
134
|
-
return rawType;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (typeof rawType === 'string' && rawType.trim() !== '') {
|
|
138
|
-
const parsed = Number(rawType.trim());
|
|
139
|
-
if (!Number.isNaN(parsed)) {
|
|
140
|
-
return parsed;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
31
|
export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
148
32
|
public readonly accessories: PlatformAccessory[] = [];
|
|
149
33
|
public configureAccessory(accessory: PlatformAccessory): void {
|
|
@@ -155,13 +39,14 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
155
39
|
private readonly config: PlatformConfig;
|
|
156
40
|
private readonly client: CyncClient;
|
|
157
41
|
private readonly tcpClient: TcpClient;
|
|
42
|
+
private readonly accessoryEnv: CyncAccessoryEnv;
|
|
158
43
|
|
|
159
44
|
private cloudConfig: CyncCloudConfig | null = null;
|
|
160
45
|
private readonly deviceIdToAccessory = new Map<string, PlatformAccessory>();
|
|
161
46
|
private readonly deviceLastSeen = new Map<string, number>();
|
|
162
47
|
private readonly devicePollTimers = new Map<string, NodeJS.Timeout>();
|
|
163
48
|
|
|
164
|
-
private readonly offlineTimeoutMs =
|
|
49
|
+
private readonly offlineTimeoutMs = 30 * 60 * 1000;
|
|
165
50
|
private readonly pollIntervalMs = 60_000; // 60 seconds
|
|
166
51
|
|
|
167
52
|
private markDeviceSeen(deviceId: string): void {
|
|
@@ -280,528 +165,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
280
165
|
}
|
|
281
166
|
}
|
|
282
167
|
|
|
283
|
-
private configureCyncSwitchAccessory(
|
|
284
|
-
mesh: CyncDeviceMesh,
|
|
285
|
-
device: CyncDevice,
|
|
286
|
-
accessory: PlatformAccessory,
|
|
287
|
-
deviceName: string,
|
|
288
|
-
deviceId: string,
|
|
289
|
-
): void {
|
|
290
|
-
const service =
|
|
291
|
-
accessory.getService(this.api.hap.Service.Switch) ||
|
|
292
|
-
accessory.addService(this.api.hap.Service.Switch, deviceName);
|
|
293
|
-
const existingLight = accessory.getService(this.api.hap.Service.Lightbulb);
|
|
294
|
-
if (existingLight) {
|
|
295
|
-
this.log.info(
|
|
296
|
-
'Cync: removing stale Lightbulb service from %s (deviceId=%s) before configuring as Switch',
|
|
297
|
-
deviceName,
|
|
298
|
-
deviceId,
|
|
299
|
-
);
|
|
300
|
-
accessory.removeService(existingLight);
|
|
301
|
-
}
|
|
302
|
-
this.applyAccessoryInformationFromCyncDevice(accessory, device, deviceName, deviceId);
|
|
303
|
-
|
|
304
|
-
// Ensure context is initialized
|
|
305
|
-
const ctx = accessory.context as CyncAccessoryContext;
|
|
306
|
-
ctx.cync = ctx.cync ?? {
|
|
307
|
-
meshId: mesh.id,
|
|
308
|
-
deviceId,
|
|
309
|
-
productId: device.product_id,
|
|
310
|
-
on: false,
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
// Remember mapping for LAN updates
|
|
314
|
-
this.deviceIdToAccessory.set(deviceId, accessory);
|
|
315
|
-
this.markDeviceSeen(deviceId);
|
|
316
|
-
this.startPollingDevice(deviceId);
|
|
317
|
-
|
|
318
|
-
service
|
|
319
|
-
.getCharacteristic(this.api.hap.Characteristic.On)
|
|
320
|
-
.onGet(() => {
|
|
321
|
-
if (this.isDeviceProbablyOffline(deviceId)) {
|
|
322
|
-
throw new this.api.hap.HapStatusError(
|
|
323
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const currentOn = !!ctx.cync?.on;
|
|
328
|
-
this.log.info(
|
|
329
|
-
'Cync: On.get -> %s for %s (deviceId=%s)',
|
|
330
|
-
String(currentOn),
|
|
331
|
-
deviceName,
|
|
332
|
-
deviceId,
|
|
333
|
-
);
|
|
334
|
-
return currentOn;
|
|
335
|
-
})
|
|
336
|
-
.onSet(async (value) => {
|
|
337
|
-
const cyncMeta = ctx.cync;
|
|
338
|
-
|
|
339
|
-
if (!cyncMeta?.deviceId) {
|
|
340
|
-
this.log.warn(
|
|
341
|
-
'Cync: Light On.set called for %s but no cync.deviceId in context',
|
|
342
|
-
deviceName,
|
|
343
|
-
);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const on = value === true || value === 1;
|
|
348
|
-
|
|
349
|
-
this.log.info(
|
|
350
|
-
'Cync: Light On.set -> %s for %s (deviceId=%s)',
|
|
351
|
-
String(on),
|
|
352
|
-
deviceName,
|
|
353
|
-
cyncMeta.deviceId,
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
cyncMeta.on = on;
|
|
357
|
-
|
|
358
|
-
if (!on) {
|
|
359
|
-
// Off is always a plain power packet
|
|
360
|
-
await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on: false });
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Turning on:
|
|
365
|
-
// - If we were in color mode with a known RGB + brightness, restore color.
|
|
366
|
-
// - Otherwise, just send a basic power-on packet.
|
|
367
|
-
if (cyncMeta.colorActive && cyncMeta.rgb && typeof cyncMeta.brightness === 'number') {
|
|
368
|
-
await this.tcpClient.setColor(cyncMeta.deviceId, cyncMeta.rgb, cyncMeta.brightness);
|
|
369
|
-
} else {
|
|
370
|
-
await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on: true });
|
|
371
|
-
}
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
private configureCyncLightAccessory(
|
|
376
|
-
mesh: CyncDeviceMesh,
|
|
377
|
-
device: CyncDevice,
|
|
378
|
-
accessory: PlatformAccessory,
|
|
379
|
-
deviceName: string,
|
|
380
|
-
deviceId: string,
|
|
381
|
-
): void {
|
|
382
|
-
// If this accessory used to be a switch, remove that service
|
|
383
|
-
const existingSwitch = accessory.getService(this.api.hap.Service.Switch);
|
|
384
|
-
if (existingSwitch) {
|
|
385
|
-
this.log.info(
|
|
386
|
-
'Cync: removing stale Switch service from %s (deviceId=%s) before configuring as Lightbulb',
|
|
387
|
-
deviceName,
|
|
388
|
-
deviceId,
|
|
389
|
-
);
|
|
390
|
-
accessory.removeService(existingSwitch);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const service =
|
|
394
|
-
accessory.getService(this.api.hap.Service.Lightbulb) ||
|
|
395
|
-
accessory.addService(this.api.hap.Service.Lightbulb, deviceName);
|
|
396
|
-
|
|
397
|
-
// Optionally update accessory category so UIs treat it as a light
|
|
398
|
-
if (accessory.category !== this.api.hap.Categories.LIGHTBULB) {
|
|
399
|
-
accessory.category = this.api.hap.Categories.LIGHTBULB;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// NEW: populate Accessory Information from Cync metadata
|
|
403
|
-
this.applyAccessoryInformationFromCyncDevice(accessory, device, deviceName, deviceId);
|
|
404
|
-
|
|
405
|
-
// Ensure context is initialized
|
|
406
|
-
const ctx = accessory.context as CyncAccessoryContext;
|
|
407
|
-
ctx.cync = ctx.cync ?? {
|
|
408
|
-
meshId: mesh.id,
|
|
409
|
-
deviceId,
|
|
410
|
-
productId: device.product_id,
|
|
411
|
-
on: false,
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
// Remember mapping for LAN updates
|
|
415
|
-
this.deviceIdToAccessory.set(deviceId, accessory);
|
|
416
|
-
this.markDeviceSeen(deviceId);
|
|
417
|
-
this.startPollingDevice(deviceId);
|
|
418
|
-
|
|
419
|
-
const Characteristic = this.api.hap.Characteristic;
|
|
420
|
-
|
|
421
|
-
// ----- On/Off -----
|
|
422
|
-
service
|
|
423
|
-
.getCharacteristic(Characteristic.On)
|
|
424
|
-
.onGet(() => {
|
|
425
|
-
if (this.isDeviceProbablyOffline(deviceId)) {
|
|
426
|
-
throw new this.api.hap.HapStatusError(
|
|
427
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const currentOn = !!ctx.cync?.on;
|
|
432
|
-
this.log.info(
|
|
433
|
-
'Cync: Light On.get -> %s for %s (deviceId=%s)',
|
|
434
|
-
String(currentOn),
|
|
435
|
-
deviceName,
|
|
436
|
-
deviceId,
|
|
437
|
-
);
|
|
438
|
-
return currentOn;
|
|
439
|
-
})
|
|
440
|
-
.onSet(async (value) => {
|
|
441
|
-
const cyncMeta = ctx.cync;
|
|
442
|
-
|
|
443
|
-
if (!cyncMeta?.deviceId) {
|
|
444
|
-
this.log.warn(
|
|
445
|
-
'Cync: Light On.set called for %s but no cync.deviceId in context',
|
|
446
|
-
deviceName,
|
|
447
|
-
);
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const on = value === true || value === 1;
|
|
452
|
-
|
|
453
|
-
this.log.info(
|
|
454
|
-
'Cync: Light On.set -> %s for %s (deviceId=%s)',
|
|
455
|
-
String(on),
|
|
456
|
-
deviceName,
|
|
457
|
-
cyncMeta.deviceId,
|
|
458
|
-
);
|
|
459
|
-
|
|
460
|
-
// Optimistic local cache; LAN update will confirm
|
|
461
|
-
cyncMeta.on = on;
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on });
|
|
465
|
-
this.markDeviceSeen(cyncMeta.deviceId);
|
|
466
|
-
} catch (err) {
|
|
467
|
-
this.log.warn(
|
|
468
|
-
'Cync: Light On.set failed for %s (deviceId=%s): %s',
|
|
469
|
-
deviceName,
|
|
470
|
-
cyncMeta.deviceId,
|
|
471
|
-
(err as Error).message ?? String(err),
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
throw new this.api.hap.HapStatusError(
|
|
475
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
});
|
|
479
|
-
// ----- Brightness (dimming via LAN combo_control) -----
|
|
480
|
-
service
|
|
481
|
-
.getCharacteristic(Characteristic.Brightness)
|
|
482
|
-
.onGet(() => {
|
|
483
|
-
if (this.isDeviceProbablyOffline(deviceId)) {
|
|
484
|
-
throw new this.api.hap.HapStatusError(
|
|
485
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
486
|
-
);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const current = ctx.cync?.brightness;
|
|
490
|
-
|
|
491
|
-
// If we have a cached LAN level, use it; otherwise infer from On.
|
|
492
|
-
if (typeof current === 'number') {
|
|
493
|
-
return current;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const on = ctx.cync?.on ?? false;
|
|
497
|
-
return on ? 100 : 0;
|
|
498
|
-
})
|
|
499
|
-
.onSet(async (value) => {
|
|
500
|
-
const cyncMeta = ctx.cync;
|
|
501
|
-
|
|
502
|
-
if (!cyncMeta?.deviceId) {
|
|
503
|
-
this.log.warn(
|
|
504
|
-
'Cync: Light Brightness.set called for %s but no cync.deviceId in context',
|
|
505
|
-
deviceName,
|
|
506
|
-
);
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const brightness = Math.max(0, Math.min(100, Number(value)));
|
|
511
|
-
|
|
512
|
-
if (!Number.isFinite(brightness)) {
|
|
513
|
-
this.log.warn(
|
|
514
|
-
'Cync: Light Brightness.set received invalid value=%o for %s (deviceId=%s)',
|
|
515
|
-
value,
|
|
516
|
-
deviceName,
|
|
517
|
-
cyncMeta.deviceId,
|
|
518
|
-
);
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Optimistic cache
|
|
523
|
-
cyncMeta.brightness = brightness;
|
|
524
|
-
cyncMeta.on = brightness > 0;
|
|
525
|
-
|
|
526
|
-
this.log.info(
|
|
527
|
-
'Cync: Light Brightness.set -> %d for %s (deviceId=%s)',
|
|
528
|
-
brightness,
|
|
529
|
-
deviceName,
|
|
530
|
-
cyncMeta.deviceId,
|
|
531
|
-
);
|
|
532
|
-
|
|
533
|
-
try {
|
|
534
|
-
// If we're in "color mode", keep the existing RGB and scale brightness via setColor();
|
|
535
|
-
// otherwise treat this as a white-brightness change.
|
|
536
|
-
if (cyncMeta.colorActive && cyncMeta.rgb) {
|
|
537
|
-
await this.tcpClient.setColor(
|
|
538
|
-
cyncMeta.deviceId,
|
|
539
|
-
cyncMeta.rgb,
|
|
540
|
-
brightness,
|
|
541
|
-
);
|
|
542
|
-
} else {
|
|
543
|
-
await this.tcpClient.setBrightness(cyncMeta.deviceId, brightness);
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
this.markDeviceSeen(cyncMeta.deviceId);
|
|
547
|
-
} catch (err) {
|
|
548
|
-
this.log.warn(
|
|
549
|
-
'Cync: Light Brightness.set failed for %s (deviceId=%s): %s',
|
|
550
|
-
deviceName,
|
|
551
|
-
cyncMeta.deviceId,
|
|
552
|
-
(err as Error).message ?? String(err),
|
|
553
|
-
);
|
|
554
|
-
|
|
555
|
-
throw new this.api.hap.HapStatusError(
|
|
556
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
557
|
-
);
|
|
558
|
-
}
|
|
559
|
-
});
|
|
560
|
-
// ----- Hue -----
|
|
561
|
-
service
|
|
562
|
-
.getCharacteristic(Characteristic.Hue)
|
|
563
|
-
.onGet(() => {
|
|
564
|
-
if (this.isDeviceProbablyOffline(deviceId)) {
|
|
565
|
-
throw new this.api.hap.HapStatusError(
|
|
566
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const hue = ctx.cync?.hue;
|
|
571
|
-
if (typeof hue === 'number') {
|
|
572
|
-
return hue;
|
|
573
|
-
}
|
|
574
|
-
// Default to 0° (red) if we have no color history
|
|
575
|
-
return 0;
|
|
576
|
-
})
|
|
577
|
-
.onSet(async (value) => {
|
|
578
|
-
const cyncMeta = ctx.cync;
|
|
579
|
-
|
|
580
|
-
if (!cyncMeta?.deviceId) {
|
|
581
|
-
this.log.warn(
|
|
582
|
-
'Cync: Light Hue.set called for %s but no cync.deviceId in context',
|
|
583
|
-
deviceName,
|
|
584
|
-
);
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
const hue = Math.max(0, Math.min(360, Number(value)));
|
|
589
|
-
if (!Number.isFinite(hue)) {
|
|
590
|
-
this.log.warn(
|
|
591
|
-
'Cync: Light Hue.set received invalid value=%o for %s (deviceId=%s)',
|
|
592
|
-
value,
|
|
593
|
-
deviceName,
|
|
594
|
-
cyncMeta.deviceId,
|
|
595
|
-
);
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Use cached saturation/brightness if available, otherwise sane defaults
|
|
600
|
-
const saturation =
|
|
601
|
-
typeof cyncMeta.saturation === 'number' ? cyncMeta.saturation : 100;
|
|
602
|
-
|
|
603
|
-
const brightness =
|
|
604
|
-
typeof cyncMeta.brightness === 'number' ? cyncMeta.brightness : 100;
|
|
605
|
-
|
|
606
|
-
const rgb = hsvToRgb(hue, saturation, brightness);
|
|
607
|
-
|
|
608
|
-
// Optimistic cache
|
|
609
|
-
cyncMeta.hue = hue;
|
|
610
|
-
cyncMeta.saturation = saturation;
|
|
611
|
-
cyncMeta.rgb = rgb;
|
|
612
|
-
cyncMeta.colorActive = true;
|
|
613
|
-
cyncMeta.on = brightness > 0;
|
|
614
|
-
cyncMeta.brightness = brightness;
|
|
615
|
-
|
|
616
|
-
this.log.info(
|
|
617
|
-
'Cync: Light Hue.set -> %d for %s (deviceId=%s) -> rgb=(%d,%d,%d) brightness=%d',
|
|
618
|
-
hue,
|
|
619
|
-
deviceName,
|
|
620
|
-
cyncMeta.deviceId,
|
|
621
|
-
rgb.r,
|
|
622
|
-
rgb.g,
|
|
623
|
-
rgb.b,
|
|
624
|
-
brightness,
|
|
625
|
-
);
|
|
626
|
-
|
|
627
|
-
try {
|
|
628
|
-
await this.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
|
|
629
|
-
this.markDeviceSeen(cyncMeta.deviceId);
|
|
630
|
-
} catch (err) {
|
|
631
|
-
this.log.warn(
|
|
632
|
-
'Cync: Light Hue.set failed for %s (deviceId=%s): %s',
|
|
633
|
-
deviceName,
|
|
634
|
-
cyncMeta.deviceId,
|
|
635
|
-
(err as Error).message ?? String(err),
|
|
636
|
-
);
|
|
637
|
-
|
|
638
|
-
throw new this.api.hap.HapStatusError(
|
|
639
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
640
|
-
);
|
|
641
|
-
}
|
|
642
|
-
});
|
|
643
|
-
// ----- Saturation -----
|
|
644
|
-
service
|
|
645
|
-
.getCharacteristic(Characteristic.Saturation)
|
|
646
|
-
.onGet(() => {
|
|
647
|
-
if (this.isDeviceProbablyOffline(deviceId)) {
|
|
648
|
-
throw new this.api.hap.HapStatusError(
|
|
649
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
650
|
-
);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const sat = ctx.cync?.saturation;
|
|
654
|
-
if (typeof sat === 'number') {
|
|
655
|
-
return sat;
|
|
656
|
-
}
|
|
657
|
-
return 100;
|
|
658
|
-
})
|
|
659
|
-
.onSet(async (value) => {
|
|
660
|
-
const cyncMeta = ctx.cync;
|
|
661
|
-
|
|
662
|
-
if (!cyncMeta?.deviceId) {
|
|
663
|
-
this.log.warn(
|
|
664
|
-
'Cync: Light Saturation.set called for %s but no cync.deviceId in context',
|
|
665
|
-
deviceName,
|
|
666
|
-
);
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const saturation = Math.max(0, Math.min(100, Number(value)));
|
|
671
|
-
if (!Number.isFinite(saturation)) {
|
|
672
|
-
this.log.warn(
|
|
673
|
-
'Cync: Light Saturation.set received invalid value=%o for %s (deviceId=%s)',
|
|
674
|
-
value,
|
|
675
|
-
deviceName,
|
|
676
|
-
cyncMeta.deviceId,
|
|
677
|
-
);
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const hue = typeof cyncMeta.hue === 'number' ? cyncMeta.hue : 0;
|
|
682
|
-
|
|
683
|
-
const brightness =
|
|
684
|
-
typeof cyncMeta.brightness === 'number' ? cyncMeta.brightness : 100;
|
|
685
|
-
|
|
686
|
-
const rgb = hsvToRgb(hue, saturation, brightness);
|
|
687
|
-
|
|
688
|
-
// Optimistic cache
|
|
689
|
-
cyncMeta.hue = hue;
|
|
690
|
-
cyncMeta.saturation = saturation;
|
|
691
|
-
cyncMeta.rgb = rgb;
|
|
692
|
-
cyncMeta.colorActive = true;
|
|
693
|
-
cyncMeta.on = brightness > 0;
|
|
694
|
-
cyncMeta.brightness = brightness;
|
|
695
|
-
|
|
696
|
-
this.log.info(
|
|
697
|
-
'Cync: Light Saturation.set -> %d for %s (deviceId=%s) -> rgb=(%d,%d,%d) brightness=%d',
|
|
698
|
-
saturation,
|
|
699
|
-
deviceName,
|
|
700
|
-
cyncMeta.deviceId,
|
|
701
|
-
rgb.r,
|
|
702
|
-
rgb.g,
|
|
703
|
-
rgb.b,
|
|
704
|
-
brightness,
|
|
705
|
-
);
|
|
706
|
-
|
|
707
|
-
try {
|
|
708
|
-
await this.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
|
|
709
|
-
this.markDeviceSeen(cyncMeta.deviceId);
|
|
710
|
-
} catch (err) {
|
|
711
|
-
this.log.warn(
|
|
712
|
-
'Cync: Light Saturation.set failed for %s (deviceId=%s): %s',
|
|
713
|
-
deviceName,
|
|
714
|
-
cyncMeta.deviceId,
|
|
715
|
-
(err as Error).message ?? String(err),
|
|
716
|
-
);
|
|
717
|
-
|
|
718
|
-
throw new this.api.hap.HapStatusError(
|
|
719
|
-
this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
private applyAccessoryInformationFromCyncDevice(
|
|
726
|
-
accessory: PlatformAccessory,
|
|
727
|
-
device: CyncDevice,
|
|
728
|
-
deviceName: string,
|
|
729
|
-
deviceId: string,
|
|
730
|
-
): void {
|
|
731
|
-
const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation);
|
|
732
|
-
if (!infoService) {
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const Characteristic = this.api.hap.Characteristic;
|
|
737
|
-
const deviceWithRaw = device as CyncDeviceWithRaw;
|
|
738
|
-
const rawDevice = deviceWithRaw.raw ?? {};
|
|
739
|
-
|
|
740
|
-
// Name: keep in sync with how we present the accessory
|
|
741
|
-
const name = deviceName || accessory.displayName;
|
|
742
|
-
infoService.updateCharacteristic(Characteristic.Name, name);
|
|
743
|
-
|
|
744
|
-
// Manufacturer: fixed for all Cync devices
|
|
745
|
-
infoService.updateCharacteristic(Characteristic.Manufacturer, 'GE Lighting');
|
|
746
|
-
|
|
747
|
-
// Model: prefer catalog entry (Cync app-style model name), fall back to raw info
|
|
748
|
-
const resolvedType = resolveDeviceType(device);
|
|
749
|
-
const catalogEntry = typeof resolvedType === 'number'
|
|
750
|
-
? lookupDeviceModel(resolvedType)
|
|
751
|
-
: undefined;
|
|
752
|
-
|
|
753
|
-
let model: string;
|
|
754
|
-
|
|
755
|
-
if (catalogEntry) {
|
|
756
|
-
// Use the Cync app-style model name
|
|
757
|
-
model = catalogEntry.modelName;
|
|
758
|
-
|
|
759
|
-
// Persist for debugging / future use
|
|
760
|
-
const ctx = accessory.context as Record<string, unknown>;
|
|
761
|
-
ctx.deviceType = resolvedType;
|
|
762
|
-
ctx.modelName = catalogEntry.modelName;
|
|
763
|
-
if (catalogEntry.marketingName) {
|
|
764
|
-
ctx.marketingName = catalogEntry.marketingName;
|
|
765
|
-
}
|
|
766
|
-
} else {
|
|
767
|
-
// Fallback: use device displayName + type
|
|
768
|
-
const modelBase =
|
|
769
|
-
typeof rawDevice.displayName === 'string' && rawDevice.displayName.trim().length > 0
|
|
770
|
-
? rawDevice.displayName.trim()
|
|
771
|
-
: 'Cync Device';
|
|
772
|
-
|
|
773
|
-
const modelSuffix =
|
|
774
|
-
typeof resolvedType === 'number'
|
|
775
|
-
? ` (Type ${resolvedType})`
|
|
776
|
-
: '';
|
|
777
|
-
|
|
778
|
-
model = modelBase + modelSuffix;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
infoService.updateCharacteristic(Characteristic.Model, model);
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
// Serial: prefer wifiMac, then mac, then deviceID, then the string deviceId
|
|
785
|
-
const serial =
|
|
786
|
-
(typeof rawDevice.wifiMac === 'string' && rawDevice.wifiMac.trim().length > 0)
|
|
787
|
-
? rawDevice.wifiMac.trim()
|
|
788
|
-
: (typeof rawDevice.mac === 'string' && rawDevice.mac.trim().length > 0)
|
|
789
|
-
? rawDevice.mac.trim()
|
|
790
|
-
: (rawDevice.deviceID !== undefined
|
|
791
|
-
? String(rawDevice.deviceID)
|
|
792
|
-
: deviceId);
|
|
793
|
-
|
|
794
|
-
infoService.updateCharacteristic(Characteristic.SerialNumber, serial);
|
|
795
|
-
|
|
796
|
-
// Firmware revision, if present
|
|
797
|
-
if (typeof rawDevice.firmwareVersion === 'string' && rawDevice.firmwareVersion.trim().length > 0) {
|
|
798
|
-
infoService.updateCharacteristic(
|
|
799
|
-
Characteristic.FirmwareRevision,
|
|
800
|
-
rawDevice.firmwareVersion.trim(),
|
|
801
|
-
);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
168
|
constructor(log: Logger, config: PlatformConfig, api: API) {
|
|
806
169
|
this.log = log;
|
|
807
170
|
this.config = config;
|
|
@@ -856,6 +219,17 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
856
219
|
this.log.info(PLATFORM_NAME, 'didFinishLaunching');
|
|
857
220
|
void this.loadCync();
|
|
858
221
|
});
|
|
222
|
+
this.accessoryEnv = {
|
|
223
|
+
log: this.log,
|
|
224
|
+
api: this.api,
|
|
225
|
+
tcpClient: this.tcpClient,
|
|
226
|
+
isDeviceProbablyOffline: this.isDeviceProbablyOffline.bind(this),
|
|
227
|
+
markDeviceSeen: this.markDeviceSeen.bind(this),
|
|
228
|
+
startPollingDevice: this.startPollingDevice.bind(this),
|
|
229
|
+
registerAccessoryForDevice: (deviceId, accessory) => {
|
|
230
|
+
this.deviceIdToAccessory.set(deviceId, accessory);
|
|
231
|
+
},
|
|
232
|
+
};
|
|
859
233
|
}
|
|
860
234
|
|
|
861
235
|
private async loadCync(): Promise<void> {
|
|
@@ -985,21 +359,35 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
985
359
|
const isDownlight = deviceType === 46;
|
|
986
360
|
|
|
987
361
|
if (isDownlight) {
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
362
|
+
this.log.info(
|
|
363
|
+
'Cync: configuring %s as Lightbulb (deviceType=%s, deviceId=%s)',
|
|
364
|
+
deviceName,
|
|
365
|
+
String(deviceType),
|
|
366
|
+
deviceId,
|
|
367
|
+
);
|
|
368
|
+
configureCyncLightAccessory(
|
|
369
|
+
this.accessoryEnv,
|
|
370
|
+
mesh,
|
|
371
|
+
device,
|
|
372
|
+
accessory,
|
|
373
|
+
deviceName,
|
|
374
|
+
deviceId,
|
|
375
|
+
);
|
|
995
376
|
} else {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
377
|
+
this.log.info(
|
|
378
|
+
'Cync: configuring %s as Switch (deviceType=%s, deviceId=%s)',
|
|
379
|
+
deviceName,
|
|
380
|
+
deviceType ?? 'unknown',
|
|
381
|
+
deviceId,
|
|
382
|
+
);
|
|
383
|
+
configureCyncSwitchAccessory(
|
|
384
|
+
this.accessoryEnv,
|
|
385
|
+
mesh,
|
|
386
|
+
device,
|
|
387
|
+
accessory,
|
|
388
|
+
deviceName,
|
|
389
|
+
deviceId,
|
|
390
|
+
);
|
|
1003
391
|
}
|
|
1004
392
|
}
|
|
1005
393
|
}
|