homebridge-cync-app 0.1.5 → 0.1.7
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/.github/ISSUE_TEMPLATE/add-support-for-a-new-cync-device.md +139 -0
- package/CHANGELOG.md +30 -0
- package/README.md +20 -3
- package/dist/cync/config-client.d.ts +12 -4
- package/dist/cync/config-client.js +55 -1
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-client.d.ts +4 -0
- package/dist/cync/cync-client.js +92 -2
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/device-catalog.d.ts +19 -0
- package/dist/cync/device-catalog.js +35 -0
- package/dist/cync/device-catalog.js.map +1 -0
- package/dist/cync/tcp-client.d.ts +7 -18
- package/dist/cync/tcp-client.js +117 -22
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.d.ts +4 -0
- package/dist/cync/token-store.js +10 -2
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +9 -12
- package/dist/platform.js +415 -27
- 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/package.json +6 -3
- package/src/cync/config-client.ts +80 -6
- package/src/cync/cync-client.ts +136 -2
- package/src/cync/device-catalog.ts +55 -0
- package/src/cync/tcp-client.ts +214 -23
- package/src/cync/token-store.ts +11 -2
- package/src/platform.ts +687 -34
- 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
|
@@ -13,6 +13,24 @@ import { ConfigClient } from './cync/config-client.js';
|
|
|
13
13
|
import type { CyncCloudConfig, CyncDevice, CyncDeviceMesh } 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 { lookupDeviceModel } from './cync/device-catalog.js';
|
|
17
|
+
|
|
18
|
+
// Narrowed view of the Cync device properties returned by getDeviceProperties()
|
|
19
|
+
type CyncDeviceRaw = {
|
|
20
|
+
displayName?: string;
|
|
21
|
+
firmwareVersion?: string;
|
|
22
|
+
mac?: string;
|
|
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
34
|
|
|
17
35
|
const toCyncLogger = (log: Logger): CyncLogger => ({
|
|
18
36
|
debug: log.debug.bind(log),
|
|
@@ -26,19 +44,106 @@ interface CyncAccessoryContext {
|
|
|
26
44
|
meshId: string;
|
|
27
45
|
deviceId: string;
|
|
28
46
|
productId?: string;
|
|
47
|
+
|
|
29
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
|
|
30
56
|
};
|
|
31
57
|
[key: string]: unknown;
|
|
32
58
|
}
|
|
33
59
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
|
|
42
147
|
export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
43
148
|
public readonly accessories: PlatformAccessory[] = [];
|
|
44
149
|
public configureAccessory(accessory: PlatformAccessory): void {
|
|
@@ -53,16 +158,58 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
53
158
|
|
|
54
159
|
private cloudConfig: CyncCloudConfig | null = null;
|
|
55
160
|
private readonly deviceIdToAccessory = new Map<string, PlatformAccessory>();
|
|
161
|
+
private readonly deviceLastSeen = new Map<string, number>();
|
|
162
|
+
private readonly devicePollTimers = new Map<string, NodeJS.Timeout>();
|
|
163
|
+
|
|
164
|
+
private readonly offlineTimeoutMs = 5 * 60 * 1000; // 5 minutes
|
|
165
|
+
private readonly pollIntervalMs = 60_000; // 60 seconds
|
|
166
|
+
|
|
167
|
+
private markDeviceSeen(deviceId: string): void {
|
|
168
|
+
this.deviceLastSeen.set(deviceId, Date.now());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private isDeviceProbablyOffline(deviceId: string): boolean {
|
|
172
|
+
const last = this.deviceLastSeen.get(deviceId);
|
|
173
|
+
if (!last) {
|
|
174
|
+
// No data yet; treat as online until we know better
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return Date.now() - last > this.offlineTimeoutMs;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private startPollingDevice(deviceId: string): void {
|
|
181
|
+
// For now this is just a placeholder hook. We keep a timer per device so
|
|
182
|
+
// you can later add a real poll (e.g. TCP “ping” or cloud get) here if you want.
|
|
183
|
+
const existing = this.devicePollTimers.get(deviceId);
|
|
184
|
+
if (existing) {
|
|
185
|
+
clearInterval(existing);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const timer = setInterval(() => {
|
|
189
|
+
// Optional future hook:
|
|
190
|
+
// - Call a "getDeviceState" or similar on tcpClient/client
|
|
191
|
+
// - On success, call this.markDeviceSeen(deviceId)
|
|
192
|
+
// - On failure, optionally log or mark offline
|
|
193
|
+
}, this.pollIntervalMs);
|
|
194
|
+
|
|
195
|
+
this.devicePollTimers.set(deviceId, timer);
|
|
196
|
+
}
|
|
197
|
+
|
|
56
198
|
private handleLanUpdate(update: unknown): void {
|
|
57
|
-
//
|
|
58
|
-
// { controllerId: number, on: boolean, level: number
|
|
59
|
-
const payload = update as {
|
|
199
|
+
// Parsed 0x83 frames from TcpClient.parseLanSwitchUpdate look like:
|
|
200
|
+
// { controllerId: number, deviceId?: string, on: boolean, level: number }
|
|
201
|
+
const payload = update as {
|
|
202
|
+
deviceId?: string;
|
|
203
|
+
on?: boolean;
|
|
204
|
+
level?: number;
|
|
205
|
+
};
|
|
60
206
|
|
|
61
|
-
if (!payload || typeof payload.deviceId !== 'string'
|
|
207
|
+
if (!payload || typeof payload.deviceId !== 'string') {
|
|
62
208
|
return;
|
|
63
209
|
}
|
|
64
210
|
|
|
65
211
|
const accessory = this.deviceIdToAccessory.get(payload.deviceId);
|
|
212
|
+
this.markDeviceSeen(payload.deviceId);
|
|
66
213
|
if (!accessory) {
|
|
67
214
|
this.log.debug(
|
|
68
215
|
'Cync: LAN update for unknown deviceId=%s; no accessory mapping',
|
|
@@ -71,10 +218,16 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
71
218
|
return;
|
|
72
219
|
}
|
|
73
220
|
|
|
74
|
-
const
|
|
75
|
-
|
|
221
|
+
const Service = this.api.hap.Service;
|
|
222
|
+
const Characteristic = this.api.hap.Characteristic;
|
|
223
|
+
|
|
224
|
+
const lightService = accessory.getService(Service.Lightbulb);
|
|
225
|
+
const switchService = accessory.getService(Service.Switch);
|
|
226
|
+
const primaryService = lightService || switchService;
|
|
227
|
+
|
|
228
|
+
if (!primaryService) {
|
|
76
229
|
this.log.debug(
|
|
77
|
-
'Cync: accessory %s has no Switch service for deviceId=%s',
|
|
230
|
+
'Cync: accessory %s has no Lightbulb or Switch service for deviceId=%s',
|
|
78
231
|
accessory.displayName,
|
|
79
232
|
payload.deviceId,
|
|
80
233
|
);
|
|
@@ -87,17 +240,44 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
87
240
|
meshId: '',
|
|
88
241
|
deviceId: payload.deviceId,
|
|
89
242
|
};
|
|
90
|
-
ctx.cync.on = payload.on;
|
|
91
243
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
244
|
+
// ----- On/off -----
|
|
245
|
+
if (typeof payload.on === 'boolean') {
|
|
246
|
+
ctx.cync.on = payload.on;
|
|
247
|
+
|
|
248
|
+
this.log.info(
|
|
249
|
+
'Cync: LAN update -> %s is now %s (deviceId=%s)',
|
|
250
|
+
accessory.displayName,
|
|
251
|
+
payload.on ? 'ON' : 'OFF',
|
|
252
|
+
payload.deviceId,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
primaryService.updateCharacteristic(Characteristic.On, payload.on);
|
|
256
|
+
}
|
|
98
257
|
|
|
99
|
-
//
|
|
100
|
-
|
|
258
|
+
// ----- Brightness (LAN "level" 0–100) -----
|
|
259
|
+
if (typeof payload.level === 'number' && lightService) {
|
|
260
|
+
const brightness = Math.max(
|
|
261
|
+
0,
|
|
262
|
+
Math.min(100, Math.round(payload.level)),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
ctx.cync.brightness = brightness;
|
|
266
|
+
|
|
267
|
+
this.log.debug(
|
|
268
|
+
'Cync: LAN update -> %s brightness=%d (deviceId=%s)',
|
|
269
|
+
accessory.displayName,
|
|
270
|
+
brightness,
|
|
271
|
+
payload.deviceId,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (lightService.testCharacteristic(Characteristic.Brightness)) {
|
|
275
|
+
lightService.updateCharacteristic(
|
|
276
|
+
Characteristic.Brightness,
|
|
277
|
+
brightness,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
101
281
|
}
|
|
102
282
|
|
|
103
283
|
private configureCyncSwitchAccessory(
|
|
@@ -110,6 +290,16 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
110
290
|
const service =
|
|
111
291
|
accessory.getService(this.api.hap.Service.Switch) ||
|
|
112
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);
|
|
113
303
|
|
|
114
304
|
// Ensure context is initialized
|
|
115
305
|
const ctx = accessory.context as CyncAccessoryContext;
|
|
@@ -122,10 +312,18 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
122
312
|
|
|
123
313
|
// Remember mapping for LAN updates
|
|
124
314
|
this.deviceIdToAccessory.set(deviceId, accessory);
|
|
315
|
+
this.markDeviceSeen(deviceId);
|
|
316
|
+
this.startPollingDevice(deviceId);
|
|
125
317
|
|
|
126
318
|
service
|
|
127
319
|
.getCharacteristic(this.api.hap.Characteristic.On)
|
|
128
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
|
+
|
|
129
327
|
const currentOn = !!ctx.cync?.on;
|
|
130
328
|
this.log.info(
|
|
131
329
|
'Cync: On.get -> %s for %s (deviceId=%s)',
|
|
@@ -140,7 +338,7 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
140
338
|
|
|
141
339
|
if (!cyncMeta?.deviceId) {
|
|
142
340
|
this.log.warn(
|
|
143
|
-
'Cync: On.set called for %s but no cync.deviceId in context',
|
|
341
|
+
'Cync: Light On.set called for %s but no cync.deviceId in context',
|
|
144
342
|
deviceName,
|
|
145
343
|
);
|
|
146
344
|
return;
|
|
@@ -149,7 +347,111 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
149
347
|
const on = value === true || value === 1;
|
|
150
348
|
|
|
151
349
|
this.log.info(
|
|
152
|
-
'Cync: On.set -> %s for %s (deviceId=%s)',
|
|
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)',
|
|
153
455
|
String(on),
|
|
154
456
|
deviceName,
|
|
155
457
|
cyncMeta.deviceId,
|
|
@@ -158,8 +460,346 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
158
460
|
// Optimistic local cache; LAN update will confirm
|
|
159
461
|
cyncMeta.on = on;
|
|
160
462
|
|
|
161
|
-
|
|
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
|
+
}
|
|
162
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
|
+
}
|
|
163
803
|
}
|
|
164
804
|
|
|
165
805
|
constructor(log: Logger, config: PlatformConfig, api: API) {
|
|
@@ -171,7 +811,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
171
811
|
const raw = this.config as Record<string, unknown>;
|
|
172
812
|
|
|
173
813
|
// Canonical config keys: username, password, twoFactor
|
|
174
|
-
// Accept legacy "email" as a fallback source for username, but do not write it back.
|
|
175
814
|
const username =
|
|
176
815
|
typeof raw.username === 'string'
|
|
177
816
|
? raw.username
|
|
@@ -291,10 +930,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
291
930
|
}
|
|
292
931
|
}
|
|
293
932
|
|
|
294
|
-
/**
|
|
295
|
-
* Discover devices from the Cync cloud config and register them as
|
|
296
|
-
* Homebridge accessories.
|
|
297
|
-
*/
|
|
298
933
|
private discoverDevices(cloudConfig: CyncCloudConfig): void {
|
|
299
934
|
if (!cloudConfig.meshes?.length) {
|
|
300
935
|
this.log.warn('Cync: no meshes returned from cloud; nothing to discover.');
|
|
@@ -346,9 +981,27 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
|
|
|
346
981
|
this.accessories.push(accessory);
|
|
347
982
|
}
|
|
348
983
|
|
|
349
|
-
|
|
984
|
+
const deviceType = resolveDeviceType(device);
|
|
985
|
+
const isDownlight = deviceType === 46;
|
|
986
|
+
|
|
987
|
+
if (isDownlight) {
|
|
988
|
+
this.log.info(
|
|
989
|
+
'Cync: configuring %s as Lightbulb (deviceType=%s, deviceId=%s)',
|
|
990
|
+
deviceName,
|
|
991
|
+
String(deviceType),
|
|
992
|
+
deviceId,
|
|
993
|
+
);
|
|
994
|
+
this.configureCyncLightAccessory(mesh, device, accessory, deviceName, deviceId);
|
|
995
|
+
} else {
|
|
996
|
+
this.log.info(
|
|
997
|
+
'Cync: configuring %s as Switch (deviceType=%s, deviceId=%s)',
|
|
998
|
+
deviceName,
|
|
999
|
+
deviceType ?? 'unknown',
|
|
1000
|
+
deviceId,
|
|
1001
|
+
);
|
|
1002
|
+
this.configureCyncSwitchAccessory(mesh, device, accessory, deviceName, deviceId);
|
|
1003
|
+
}
|
|
350
1004
|
}
|
|
351
1005
|
}
|
|
352
1006
|
}
|
|
353
|
-
|
|
354
1007
|
}
|