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/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
- * 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
- */
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
- // We only care about parsed 0x83 frames that look like:
55
- // { controllerId: number, on: boolean, level: number, deviceId?: string }
56
- const payload = update as { deviceId?: string; on?: boolean };
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' || typeof payload.on !== 'boolean') {
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 service = accessory.getService(this.api.hap.Service.Switch);
72
- if (!service) {
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
- this.log.info(
90
- 'Cync: LAN update -> %s is now %s (deviceId=%s)',
91
- accessory.displayName,
92
- payload.on ? 'ON' : 'OFF',
93
- payload.deviceId,
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
- // Push the new state into HomeKit
97
- service.updateCharacteristic(this.api.hap.Characteristic.On, payload.on);
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 cfg = this.config as Record<string, unknown>;
169
- const username = (cfg.username ?? cfg.email) as string | undefined;
170
- const password = cfg.password as string | undefined;
171
- const twoFactor = cfg.twoFactor as string | undefined;
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
- email: username ?? '',
181
- password: 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 cfg = this.config as Record<string, unknown>;
214
- const username = (cfg.username ?? cfg.email) as string | undefined;
215
- const password = cfg.password as string | undefined;
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.configureCyncSwitchAccessory(mesh, device, accessory, deviceName, deviceId);
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
  }