homebridge-cync-app 0.1.6 → 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.
@@ -0,0 +1,55 @@
1
+ // src/cync/device-catalog.ts
2
+
3
+ import type { Categories } from 'homebridge';
4
+
5
+ export interface CyncDeviceModel {
6
+ /** Raw deviceType from the Cync API */
7
+ deviceType: number;
8
+
9
+ /** Model name as shown in the Cync app (what you want HomeKit to show) */
10
+ modelName: string;
11
+
12
+ /** Optional marketing / retail name if you want to surface it somewhere else */
13
+ marketingName?: string;
14
+
15
+ /** Optional suggested HomeKit category override */
16
+ defaultCategory?: Categories;
17
+
18
+ /** Free-form notes for you / debugging */
19
+ notes?: string;
20
+ }
21
+
22
+ /**
23
+ * Device catalog keyed by deviceType.
24
+ * Extend this as you discover more types.
25
+ */
26
+ export const DEVICE_CATALOG: Record<number, CyncDeviceModel> = {
27
+ 46: {
28
+ deviceType: 46,
29
+ modelName: '6" Recessed Can Retrofit Fixture (Matter)',
30
+ marketingName: 'Cync reveal HD+',
31
+ // defaultCategory: Categories.LIGHTBULB,
32
+ },
33
+ 64: {
34
+ deviceType: 64,
35
+ modelName: 'Indoor Smart Plug',
36
+ marketingName: 'On/Off Smart Plug',
37
+ // defaultCategory: Categories.OUTLET,
38
+ },
39
+ 65: {
40
+ deviceType: 65,
41
+ modelName: 'Indoor Smart Plug',
42
+ marketingName: 'Cync Indoor Plug',
43
+ // defaultCategory: Categories.OUTLET,
44
+ },
45
+ 172: {
46
+ deviceType: 172,
47
+ modelName: 'Indoor Smart Plug (3in1)',
48
+ marketingName: 'Cync Indoor Smart Plug',
49
+ // defaultCategory: Categories.OUTLET,
50
+ },
51
+ };
52
+
53
+ export function lookupDeviceModel(deviceType: number): CyncDeviceModel | undefined {
54
+ return DEVICE_CATALOG[deviceType];
55
+ }
package/src/platform.ts CHANGED
@@ -13,6 +13,7 @@ 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';
16
17
 
17
18
  // Narrowed view of the Cync device properties returned by getDeviceProperties()
18
19
  type CyncDeviceRaw = {
@@ -118,6 +119,31 @@ function hsvToRgb(hue: number, saturation: number, value: number): { r: number;
118
119
  };
119
120
  }
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
+
121
147
  export class CyncAppPlatform implements DynamicPlatformPlugin {
122
148
  public readonly accessories: PlatformAccessory[] = [];
123
149
  public configureAccessory(accessory: PlatformAccessory): void {
@@ -132,6 +158,43 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
132
158
 
133
159
  private cloudConfig: CyncCloudConfig | null = null;
134
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
+
135
198
  private handleLanUpdate(update: unknown): void {
136
199
  // Parsed 0x83 frames from TcpClient.parseLanSwitchUpdate look like:
137
200
  // { controllerId: number, deviceId?: string, on: boolean, level: number }
@@ -146,6 +209,7 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
146
209
  }
147
210
 
148
211
  const accessory = this.deviceIdToAccessory.get(payload.deviceId);
212
+ this.markDeviceSeen(payload.deviceId);
149
213
  if (!accessory) {
150
214
  this.log.debug(
151
215
  'Cync: LAN update for unknown deviceId=%s; no accessory mapping',
@@ -235,6 +299,8 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
235
299
  );
236
300
  accessory.removeService(existingLight);
237
301
  }
302
+ this.applyAccessoryInformationFromCyncDevice(accessory, device, deviceName, deviceId);
303
+
238
304
  // Ensure context is initialized
239
305
  const ctx = accessory.context as CyncAccessoryContext;
240
306
  ctx.cync = ctx.cync ?? {
@@ -246,10 +312,18 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
246
312
 
247
313
  // Remember mapping for LAN updates
248
314
  this.deviceIdToAccessory.set(deviceId, accessory);
315
+ this.markDeviceSeen(deviceId);
316
+ this.startPollingDevice(deviceId);
249
317
 
250
318
  service
251
319
  .getCharacteristic(this.api.hap.Characteristic.On)
252
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
+
253
327
  const currentOn = !!ctx.cync?.on;
254
328
  this.log.info(
255
329
  'Cync: On.get -> %s for %s (deviceId=%s)',
@@ -339,6 +413,8 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
339
413
 
340
414
  // Remember mapping for LAN updates
341
415
  this.deviceIdToAccessory.set(deviceId, accessory);
416
+ this.markDeviceSeen(deviceId);
417
+ this.startPollingDevice(deviceId);
342
418
 
343
419
  const Characteristic = this.api.hap.Characteristic;
344
420
 
@@ -346,6 +422,12 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
346
422
  service
347
423
  .getCharacteristic(Characteristic.On)
348
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
+
349
431
  const currentOn = !!ctx.cync?.on;
350
432
  this.log.info(
351
433
  'Cync: Light On.get -> %s for %s (deviceId=%s)',
@@ -378,13 +460,32 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
378
460
  // Optimistic local cache; LAN update will confirm
379
461
  cyncMeta.on = on;
380
462
 
381
- await this.tcpClient.setSwitchState(cyncMeta.deviceId, { on });
382
- });
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
+ );
383
473
 
474
+ throw new this.api.hap.HapStatusError(
475
+ this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
476
+ );
477
+ }
478
+ });
384
479
  // ----- Brightness (dimming via LAN combo_control) -----
385
480
  service
386
481
  .getCharacteristic(Characteristic.Brightness)
387
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
+
388
489
  const current = ctx.cync?.brightness;
389
490
 
390
491
  // If we have a cached LAN level, use it; otherwise infer from On.
@@ -406,10 +507,7 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
406
507
  return;
407
508
  }
408
509
 
409
- const brightness = Math.max(
410
- 0,
411
- Math.min(100, Number(value)),
412
- );
510
+ const brightness = Math.max(0, Math.min(100, Number(value)));
413
511
 
414
512
  if (!Number.isFinite(brightness)) {
415
513
  this.log.warn(
@@ -432,18 +530,43 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
432
530
  cyncMeta.deviceId,
433
531
  );
434
532
 
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);
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
+ );
441
558
  }
442
559
  });
443
560
  // ----- Hue -----
444
561
  service
445
562
  .getCharacteristic(Characteristic.Hue)
446
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
+
447
570
  const hue = ctx.cync?.hue;
448
571
  if (typeof hue === 'number') {
449
572
  return hue;
@@ -474,13 +597,11 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
474
597
  }
475
598
 
476
599
  // Use cached saturation/brightness if available, otherwise sane defaults
477
- const saturation = typeof cyncMeta.saturation === 'number'
478
- ? cyncMeta.saturation
479
- : 100;
600
+ const saturation =
601
+ typeof cyncMeta.saturation === 'number' ? cyncMeta.saturation : 100;
480
602
 
481
- const brightness = typeof cyncMeta.brightness === 'number'
482
- ? cyncMeta.brightness
483
- : 100;
603
+ const brightness =
604
+ typeof cyncMeta.brightness === 'number' ? cyncMeta.brightness : 100;
484
605
 
485
606
  const rgb = hsvToRgb(hue, saturation, brightness);
486
607
 
@@ -503,13 +624,32 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
503
624
  brightness,
504
625
  );
505
626
 
506
- await this.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
507
- });
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
+ );
508
637
 
638
+ throw new this.api.hap.HapStatusError(
639
+ this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE,
640
+ );
641
+ }
642
+ });
509
643
  // ----- Saturation -----
510
644
  service
511
645
  .getCharacteristic(Characteristic.Saturation)
512
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
+
513
653
  const sat = ctx.cync?.saturation;
514
654
  if (typeof sat === 'number') {
515
655
  return sat;
@@ -538,13 +678,10 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
538
678
  return;
539
679
  }
540
680
 
541
- const hue = typeof cyncMeta.hue === 'number'
542
- ? cyncMeta.hue
543
- : 0;
681
+ const hue = typeof cyncMeta.hue === 'number' ? cyncMeta.hue : 0;
544
682
 
545
- const brightness = typeof cyncMeta.brightness === 'number'
546
- ? cyncMeta.brightness
547
- : 100;
683
+ const brightness =
684
+ typeof cyncMeta.brightness === 'number' ? cyncMeta.brightness : 100;
548
685
 
549
686
  const rgb = hsvToRgb(hue, saturation, brightness);
550
687
 
@@ -567,7 +704,21 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
567
704
  brightness,
568
705
  );
569
706
 
570
- await this.tcpClient.setColor(cyncMeta.deviceId, rgb, brightness);
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
+ }
571
722
  });
572
723
  }
573
724
 
@@ -593,21 +744,42 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
593
744
  // Manufacturer: fixed for all Cync devices
594
745
  infoService.updateCharacteristic(Characteristic.Manufacturer, 'GE Lighting');
595
746
 
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';
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;
601
752
 
602
- const modelSuffix =
603
- typeof rawDevice.deviceType === 'number'
604
- ? ` (Type ${rawDevice.deviceType})`
605
- : '';
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);
606
782
 
607
- infoService.updateCharacteristic(
608
- Characteristic.Model,
609
- modelBase + modelSuffix,
610
- );
611
783
 
612
784
  // Serial: prefer wifiMac, then mac, then deviceID, then the string deviceId
613
785
  const serial =
@@ -809,29 +981,7 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
809
981
  this.accessories.push(accessory);
810
982
  }
811
983
 
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
-
984
+ const deviceType = resolveDeviceType(device);
835
985
  const isDownlight = deviceType === 46;
836
986
 
837
987
  if (isDownlight) {