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/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
- * CyncAppPlatform
36
- *
37
- * Homebridge platform class responsible for:
38
- * - Initializing the Cync client
39
- * - Managing cached accessories
40
- * - Kicking off device discovery from Cync cloud
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
- // We only care about parsed 0x83 frames that look like:
58
- // { controllerId: number, on: boolean, level: number, deviceId?: string }
59
- const payload = update as { deviceId?: string; on?: boolean };
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' || typeof payload.on !== 'boolean') {
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 service = accessory.getService(this.api.hap.Service.Switch);
75
- if (!service) {
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
- this.log.info(
93
- 'Cync: LAN update -> %s is now %s (deviceId=%s)',
94
- accessory.displayName,
95
- payload.on ? 'ON' : 'OFF',
96
- payload.deviceId,
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
- // Push the new state into HomeKit
100
- service.updateCharacteristic(this.api.hap.Characteristic.On, payload.on);
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
- await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on });
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
- this.configureCyncSwitchAccessory(mesh, device, accessory, deviceName, deviceId);
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
  }