homebridge-cync-app 0.1.5 → 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.
@@ -38,12 +38,7 @@ export class TcpClient {
38
38
  private heartbeatTimer: NodeJS.Timeout | null = null;
39
39
  private rawFrameListeners: RawFrameListener[] = [];
40
40
  private controllerToDevice = new Map<number, string>();
41
- /**
42
- * ### 🧩 LAN Command Serializer: Ensures TCP commands are sent one at a time
43
- *
44
- * Wraps any TCP command in a promise chain so commands are serialized
45
- * and cannot be written to the socket concurrently.
46
- */
41
+
47
42
  private enqueueCommand<T>(fn: () => Promise<T>): Promise<T> {
48
43
  let resolveWrapper: (value: T | PromiseLike<T>) => void;
49
44
  let rejectWrapper: (reason?: unknown) => void;
@@ -179,7 +174,6 @@ export class TcpClient {
179
174
  await this.ensureConnected();
180
175
  }
181
176
 
182
-
183
177
  public applyLanTopology(topology: {
184
178
  homeDevices: Record<string, string[]>;
185
179
  switchIdToHomeId: Record<number, string>;
@@ -201,10 +195,6 @@ export class TcpClient {
201
195
  );
202
196
  }
203
197
 
204
- /**
205
- * Ensure we have an open, logged-in socket.
206
- * If the socket is closed or missing, attempt to reconnect.
207
- */
208
198
  private async ensureConnected(): Promise<boolean> {
209
199
  if (this.socket && !this.socket.destroyed) {
210
200
  return true;
@@ -221,10 +211,6 @@ export class TcpClient {
221
211
  return !!(this.socket && !this.socket.destroyed);
222
212
  }
223
213
 
224
- /**
225
- * Open a new socket to cm.gelighting.com and send the loginCode,
226
- * mirroring the HA integration’s behavior.
227
- */
228
214
  private async establishSocket(): Promise<void> {
229
215
  const host = 'cm.gelighting.com';
230
216
  const portTLS = 23779;
@@ -367,6 +353,69 @@ export class TcpClient {
367
353
  ]);
368
354
  }
369
355
 
356
+ private buildComboPacket(
357
+ controllerId: number,
358
+ meshId: number,
359
+ on: boolean,
360
+ brightnessPct: number,
361
+ colorTone: number,
362
+ rgb: { r: number; g: number; b: number },
363
+ seq: number,
364
+ ): Buffer {
365
+ const header = Buffer.from('7300000022', 'hex');
366
+
367
+ const switchBytes = Buffer.alloc(4);
368
+ switchBytes.writeUInt32BE(controllerId, 0);
369
+
370
+ const seqBytes = Buffer.alloc(2);
371
+ seqBytes.writeUInt16BE(seq, 0);
372
+
373
+ const middle = Buffer.from('007e00000000f8f010000000000000', 'hex');
374
+
375
+ const meshBytes = Buffer.alloc(2);
376
+ meshBytes.writeUInt16LE(meshId, 0);
377
+
378
+ const tailPrefix = Buffer.from('f00000', 'hex');
379
+
380
+ const onByte = on ? 1 : 0;
381
+ const brightnessByte = Math.max(0, Math.min(100, Math.round(brightnessPct)));
382
+ const colorToneByte = Math.max(0, Math.min(255, Math.round(colorTone)));
383
+
384
+ const r = Math.max(0, Math.min(255, Math.round(rgb.r)));
385
+ const g = Math.max(0, Math.min(255, Math.round(rgb.g)));
386
+ const b = Math.max(0, Math.min(255, Math.round(rgb.b)));
387
+
388
+ const rgbBytes = Buffer.from([r, g, b]);
389
+
390
+ const checksumSeed =
391
+ 496 +
392
+ meshBytes[0] +
393
+ meshBytes[1] +
394
+ onByte +
395
+ brightnessByte +
396
+ colorToneByte +
397
+ r +
398
+ g +
399
+ b;
400
+
401
+ const checksum = Buffer.from([checksumSeed & 0xff]);
402
+ const end = Buffer.from('7e', 'hex');
403
+
404
+ return Buffer.concat([
405
+ header,
406
+ switchBytes,
407
+ seqBytes,
408
+ middle,
409
+ meshBytes,
410
+ tailPrefix,
411
+ Buffer.from([onByte]),
412
+ Buffer.from([brightnessByte]),
413
+ Buffer.from([colorToneByte]),
414
+ rgbBytes,
415
+ checksum,
416
+ end,
417
+ ]);
418
+ }
370
419
  public async disconnect(): Promise<void> {
371
420
  this.log.info('[Cync TCP] disconnect() called.');
372
421
  if (this.heartbeatTimer) {
@@ -400,10 +449,6 @@ export class TcpClient {
400
449
  this.rawFrameListeners.push(listener);
401
450
  }
402
451
 
403
- /**
404
- * High-level API to change switch state.
405
- * Ensures we have a live socket before sending and serializes commands.
406
- */
407
452
  public async setSwitchState(
408
453
  deviceId: string,
409
454
  params: { on: boolean },
@@ -429,6 +474,14 @@ export class TcpClient {
429
474
  }
430
475
 
431
476
  const record = device as Record<string, unknown>;
477
+ this.log.debug(
478
+ '[Cync TCP] setSwitchState: deviceId=%s device_type=%o switch_controller=%o mesh_id=%o home_id=%o',
479
+ deviceId,
480
+ record.device_type,
481
+ record.switch_controller,
482
+ record.mesh_id,
483
+ record.home_id,
484
+ );
432
485
  const controllerId = Number(record.switch_controller);
433
486
  const meshIndex = Number(record.mesh_id);
434
487
 
@@ -456,6 +509,143 @@ export class TcpClient {
456
509
  });
457
510
  }
458
511
 
512
+ public async setBrightness(deviceId: string, brightnessPct: number): Promise<void> {
513
+ return this.enqueueCommand(async () => {
514
+ if (!this.config) {
515
+ this.log.warn('[Cync TCP] setBrightness: no config available.');
516
+ return;
517
+ }
518
+
519
+ const connected = await this.ensureConnected();
520
+ if (!connected || !this.socket || this.socket.destroyed) {
521
+ this.log.warn(
522
+ '[Cync TCP] setBrightness: socket not ready even after reconnect attempt.',
523
+ );
524
+ return;
525
+ }
526
+
527
+ const device = this.findDevice(deviceId);
528
+ if (!device) {
529
+ this.log.warn('[Cync TCP] setBrightness: unknown deviceId=%s', deviceId);
530
+ return;
531
+ }
532
+
533
+ const record = device as Record<string, unknown>;
534
+ const controllerId = Number(record.switch_controller);
535
+ const meshIndex = Number(record.mesh_id);
536
+
537
+ if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
538
+ this.log.warn(
539
+ '[Cync TCP] setBrightness: device %s missing LAN fields (switch_controller=%o mesh_id=%o)',
540
+ deviceId,
541
+ record.switch_controller,
542
+ record.mesh_id,
543
+ );
544
+ return;
545
+ }
546
+
547
+ const clamped = Math.max(0, Math.min(100, Number(brightnessPct)));
548
+ const on = clamped > 0;
549
+
550
+ const seq = this.nextSeq();
551
+
552
+ // White-only combo control for now; color support can layer on this later.
553
+ const packet = this.buildComboPacket(
554
+ controllerId,
555
+ meshIndex,
556
+ on,
557
+ clamped,
558
+ 255, // color_tone=255 ("white" in HA integration)
559
+ { r: 255, g: 255, b: 255 },
560
+ seq,
561
+ );
562
+
563
+ this.socket.write(packet);
564
+ this.log.info(
565
+ '[Cync TCP] Sent combo (brightness) packet: device=%s on=%s brightness=%d seq=%d',
566
+ deviceId,
567
+ String(on),
568
+ clamped,
569
+ seq,
570
+ );
571
+ });
572
+ }
573
+
574
+ public async setColor(
575
+ deviceId: string,
576
+ rgb: { r: number; g: number; b: number },
577
+ brightnessPct?: number,
578
+ ): Promise<void> {
579
+ return this.enqueueCommand(async () => {
580
+ if (!this.config) {
581
+ this.log.warn('[Cync TCP] setColor: no config available.');
582
+ return;
583
+ }
584
+
585
+ const connected = await this.ensureConnected();
586
+ if (!connected || !this.socket || this.socket.destroyed) {
587
+ this.log.warn(
588
+ '[Cync TCP] setColor: socket not ready even after reconnect attempt.',
589
+ );
590
+ return;
591
+ }
592
+
593
+ const device = this.findDevice(deviceId);
594
+ if (!device) {
595
+ this.log.warn('[Cync TCP] setColor: unknown deviceId=%s', deviceId);
596
+ return;
597
+ }
598
+
599
+ const record = device as Record<string, unknown>;
600
+ const controllerId = Number(record.switch_controller);
601
+ const meshIndex = Number(record.mesh_id);
602
+
603
+ if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
604
+ this.log.warn(
605
+ '[Cync TCP] setColor: device %s missing LAN fields (switch_controller=%o mesh_id=%o)',
606
+ deviceId,
607
+ record.switch_controller,
608
+ record.mesh_id,
609
+ );
610
+ return;
611
+ }
612
+
613
+ const clampedBrightness = Math.max(
614
+ 0,
615
+ Math.min(100, Math.round(brightnessPct ?? 100)),
616
+ );
617
+ const on = clampedBrightness > 0;
618
+
619
+ const r = Math.max(0, Math.min(255, Math.round(rgb.r)));
620
+ const g = Math.max(0, Math.min(255, Math.round(rgb.g)));
621
+ const b = Math.max(0, Math.min(255, Math.round(rgb.b)));
622
+
623
+ const seq = this.nextSeq();
624
+
625
+ // colorTone=254 => "color" mode, per HA's combo_control for RGB
626
+ const packet = this.buildComboPacket(
627
+ controllerId,
628
+ meshIndex,
629
+ on,
630
+ clampedBrightness,
631
+ 254,
632
+ { r, g, b },
633
+ seq,
634
+ );
635
+
636
+ this.socket.write(packet);
637
+ this.log.info(
638
+ '[Cync TCP] Sent color combo packet: device=%s on=%s brightness=%d rgb=(%d,%d,%d) seq=%d',
639
+ deviceId,
640
+ String(on),
641
+ clampedBrightness,
642
+ r,
643
+ g,
644
+ b,
645
+ seq,
646
+ );
647
+ });
648
+ }
459
649
  private findDevice(deviceId: string) {
460
650
  for (const mesh of this.config?.meshes ?? []) {
461
651
  for (const dev of mesh.devices ?? []) {
@@ -563,13 +753,14 @@ export class TcpClient {
563
753
  // Default payload is the raw frame
564
754
  let payload: unknown = frame;
565
755
 
566
- if (type === 0x83) {
567
- // Preferred path: HA-style per-device parsing using homeDevices + switchIdToHomeId
756
+ // 0x73 / 0x83 carry per-device state updates on the LAN.
757
+ // Mirror the HA integration: try the topology-based parser first.
758
+ if (type === 0x73 || type === 0x83) {
568
759
  const lanParsed = this.parseLanSwitchUpdate(frame);
569
760
  if (lanParsed) {
570
761
  payload = lanParsed;
571
- } else {
572
- // Fallback to legacy controller-level parsing
762
+ } else if (type === 0x83) {
763
+ // Fallback to legacy controller-level parsing only for 0x83
573
764
  const parsed = this.parseSwitchStateFrame(frame);
574
765
  if (parsed) {
575
766
  const deviceId = this.controllerToDevice.get(parsed.controllerId);
@@ -13,12 +13,17 @@ export interface CyncTokenData {
13
13
 
14
14
  /**
15
15
  * Simple JSON token store under the Homebridge storage path.
16
+ *
17
+ * Files are stored at:
18
+ * <storagePath>/homebridge-cync-app/cync-tokens.json
16
19
  */
17
20
  export class CyncTokenStore {
21
+ private readonly dirPath: string;
18
22
  private readonly filePath: string;
19
23
 
20
24
  public constructor(storagePath: string) {
21
- this.filePath = path.join(storagePath, 'cync-tokens.json');
25
+ this.dirPath = path.join(storagePath, 'homebridge-cync-app');
26
+ this.filePath = path.join(this.dirPath, 'cync-tokens.json');
22
27
  }
23
28
 
24
29
  public async load(): Promise<CyncTokenData | null> {
@@ -33,12 +38,16 @@ export class CyncTokenStore {
33
38
 
34
39
  return data;
35
40
  } catch {
41
+ // file missing or unreadable → treat as no token
36
42
  return null;
37
43
  }
38
44
  }
39
45
 
40
46
  public async save(data: CyncTokenData): Promise<void> {
41
47
  const json = JSON.stringify(data, null, 2);
48
+
49
+ // Ensure directory exists before writing
50
+ await fs.mkdir(this.dirPath, { recursive: true });
42
51
  await fs.writeFile(this.filePath, json, 'utf8');
43
52
  }
44
53
 
@@ -46,7 +55,7 @@ export class CyncTokenStore {
46
55
  try {
47
56
  await fs.unlink(this.filePath);
48
57
  } catch {
49
- // ignore if missing
58
+ // ignore if missing or already removed
50
59
  }
51
60
  }
52
61
  }