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.
@@ -14,13 +14,6 @@ const defaultLogger: CyncLogger = {
14
14
  export type DeviceUpdateCallback = (payload: unknown) => void;
15
15
  export type RawFrameListener = (frame: Buffer) => void;
16
16
 
17
- interface QueuedPowerPacket {
18
- deviceId: string;
19
- on: boolean;
20
- seq: number;
21
- packet: Buffer;
22
- }
23
-
24
17
  export class TcpClient {
25
18
  public registerSwitchMapping(controllerId: number, deviceId: string): void {
26
19
  if (!Number.isFinite(controllerId)) {
@@ -28,6 +21,7 @@ export class TcpClient {
28
21
  }
29
22
  this.controllerToDevice.set(controllerId, deviceId);
30
23
  }
24
+ private commandChain: Promise<void> = Promise.resolve();
31
25
  private homeDevices: Record<string, string[]> = {};
32
26
  private switchIdToHomeId = new Map<number, string>();
33
27
  private readonly log: CyncLogger;
@@ -44,9 +38,33 @@ export class TcpClient {
44
38
  private heartbeatTimer: NodeJS.Timeout | null = null;
45
39
  private rawFrameListeners: RawFrameListener[] = [];
46
40
  private controllerToDevice = new Map<number, string>();
47
- private connecting: Promise<boolean> | null = null;
48
- private readonly sendQueue: QueuedPowerPacket[] = [];
49
- private sending = false;
41
+
42
+ private enqueueCommand<T>(fn: () => Promise<T>): Promise<T> {
43
+ let resolveWrapper: (value: T | PromiseLike<T>) => void;
44
+ let rejectWrapper: (reason?: unknown) => void;
45
+
46
+ const p = new Promise<T>((resolve, reject) => {
47
+ resolveWrapper = resolve;
48
+ rejectWrapper = reject;
49
+ });
50
+
51
+ // Chain onto the existing promise
52
+ this.commandChain = this.commandChain
53
+ .then(async () => {
54
+ try {
55
+ const result = await fn();
56
+ resolveWrapper(result);
57
+ } catch (err) {
58
+ rejectWrapper(err);
59
+ }
60
+ })
61
+ .catch(() => {
62
+ // Swallow errors in the chain so a failed command
63
+ // doesn't permanently block the queue.
64
+ });
65
+
66
+ return p;
67
+ }
50
68
  private parseSwitchStateFrame(frame: Buffer): { controllerId: number; on: boolean; level: number } | null {
51
69
  if (frame.length < 16) {
52
70
  return null;
@@ -156,7 +174,6 @@ export class TcpClient {
156
174
  await this.ensureConnected();
157
175
  }
158
176
 
159
-
160
177
  public applyLanTopology(topology: {
161
178
  homeDevices: Record<string, string[]>;
162
179
  switchIdToHomeId: Record<number, string>;
@@ -178,11 +195,6 @@ export class TcpClient {
178
195
  );
179
196
  }
180
197
 
181
- /**
182
- * Ensure we have an open, logged-in socket.
183
- * If multiple callers invoke this concurrently, they will share a single
184
- * connection attempt via `this.connecting`.
185
- */
186
198
  private async ensureConnected(): Promise<boolean> {
187
199
  if (this.socket && !this.socket.destroyed) {
188
200
  return true;
@@ -195,34 +207,10 @@ export class TcpClient {
195
207
  return false;
196
208
  }
197
209
 
198
- if (!this.connecting) {
199
- this.connecting = (async () => {
200
- await this.establishSocket();
201
- const ok = !!(this.socket && !this.socket.destroyed);
202
- if (!ok) {
203
- this.log.warn(
204
- '[Cync TCP] establishSocket() completed but socket is not usable.',
205
- );
206
- }
207
- this.connecting = null;
208
- return ok;
209
- })().catch((err) => {
210
- this.log.error(
211
- '[Cync TCP] ensureConnected() connect attempt failed: %s',
212
- String(err),
213
- );
214
- this.connecting = null;
215
- return false;
216
- });
217
- }
218
-
219
- return this.connecting;
210
+ await this.establishSocket();
211
+ return !!(this.socket && !this.socket.destroyed);
220
212
  }
221
213
 
222
- /**
223
- * Open a new socket to cm.gelighting.com and send the loginCode,
224
- * mirroring the HA integration’s behavior.
225
- */
226
214
  private async establishSocket(): Promise<void> {
227
215
  const host = 'cm.gelighting.com';
228
216
  const portTLS = 23779;
@@ -325,45 +313,6 @@ export class TcpClient {
325
313
  return this.seq;
326
314
  }
327
315
 
328
- private async flushQueue(): Promise<void> {
329
- if (this.sending) {
330
- return;
331
- }
332
- this.sending = true;
333
-
334
- try {
335
- while (this.sendQueue.length > 0) {
336
- const item = this.sendQueue.shift();
337
- if (!item) {
338
- break;
339
- }
340
-
341
- const connected = await this.ensureConnected();
342
- if (!connected || !this.socket || this.socket.destroyed) {
343
- this.log.warn(
344
- '[Cync TCP] Socket not available while flushing queue; dropping remaining %d packets.',
345
- this.sendQueue.length + 1,
346
- );
347
- this.sendQueue.length = 0;
348
- break;
349
- }
350
-
351
- this.socket.write(item.packet);
352
- this.log.info(
353
- '[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
354
- item.deviceId,
355
- String(item.on),
356
- item.seq,
357
- );
358
-
359
- // Small delay so we don't hammer the bridge/device with a burst
360
- await new Promise((resolve) => setTimeout(resolve, 50));
361
- }
362
- } finally {
363
- this.sending = false;
364
- }
365
- }
366
-
367
316
  private buildPowerPacket(
368
317
  controllerId: number,
369
318
  meshId: number,
@@ -404,6 +353,69 @@ export class TcpClient {
404
353
  ]);
405
354
  }
406
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
+ }
407
419
  public async disconnect(): Promise<void> {
408
420
  this.log.info('[Cync TCP] disconnect() called.');
409
421
  if (this.heartbeatTimer) {
@@ -414,9 +426,6 @@ export class TcpClient {
414
426
  this.socket.destroy();
415
427
  this.socket = null;
416
428
  }
417
- this.sendQueue.length = 0;
418
- this.sending = false;
419
- this.connecting = null;
420
429
  }
421
430
 
422
431
  public onDeviceUpdate(cb: DeviceUpdateCallback): void {
@@ -440,53 +449,203 @@ export class TcpClient {
440
449
  this.rawFrameListeners.push(listener);
441
450
  }
442
451
 
443
- /**
444
- * High-level API to change switch state.
445
- * Now enqueues a power packet so multiple HomeKit scene writes are
446
- * serialized over a single TCP session.
447
- */
448
452
  public async setSwitchState(
449
453
  deviceId: string,
450
454
  params: { on: boolean },
451
455
  ): Promise<void> {
452
- if (!this.config) {
453
- this.log.warn('[Cync TCP] No config available.');
454
- return;
455
- }
456
+ return this.enqueueCommand(async () => {
457
+ if (!this.config) {
458
+ this.log.warn('[Cync TCP] No config available.');
459
+ return;
460
+ }
456
461
 
457
- const device = this.findDevice(deviceId);
458
- if (!device) {
459
- this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
460
- return;
461
- }
462
+ const connected = await this.ensureConnected();
463
+ if (!connected || !this.socket || this.socket.destroyed) {
464
+ this.log.warn(
465
+ '[Cync TCP] Cannot send, socket not ready even after reconnect attempt.',
466
+ );
467
+ return;
468
+ }
462
469
 
463
- const controllerId = Number((device as Record<string, unknown>).switch_controller);
464
- const meshIndex = Number((device as Record<string, unknown>).mesh_id);
470
+ const device = this.findDevice(deviceId);
471
+ if (!device) {
472
+ this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
473
+ return;
474
+ }
465
475
 
466
- if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
467
- this.log.warn(
468
- '[Cync TCP] Device %s is missing LAN fields (switch_controller=%o mesh_id=%o)',
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',
469
479
  deviceId,
470
- (device as Record<string, unknown>).switch_controller,
471
- (device as Record<string, unknown>).mesh_id,
480
+ record.device_type,
481
+ record.switch_controller,
482
+ record.mesh_id,
483
+ record.home_id,
472
484
  );
473
- return;
474
- }
485
+ const controllerId = Number(record.switch_controller);
486
+ const meshIndex = Number(record.mesh_id);
487
+
488
+ if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
489
+ this.log.warn(
490
+ '[Cync TCP] Device %s is missing LAN fields (switch_controller=%o mesh_id=%o)',
491
+ deviceId,
492
+ record.switch_controller,
493
+ record.mesh_id,
494
+ );
495
+ return;
496
+ }
475
497
 
476
- const seq = this.nextSeq();
477
- const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
498
+ const seq = this.nextSeq();
499
+ const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
478
500
 
479
- this.sendQueue.push({
480
- deviceId,
481
- on: params.on,
482
- seq,
483
- packet,
501
+ // At this point socket has been validated above
502
+ this.socket.write(packet);
503
+ this.log.info(
504
+ '[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
505
+ deviceId,
506
+ String(params.on),
507
+ seq,
508
+ );
484
509
  });
510
+ }
485
511
 
486
- // Async fire-and-forget; actual sends are serialized in flushQueue()
487
- void this.flushQueue();
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
+ });
488
572
  }
489
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
+ }
490
649
  private findDevice(deviceId: string) {
491
650
  for (const mesh of this.config?.meshes ?? []) {
492
651
  for (const dev of mesh.devices ?? []) {
@@ -515,9 +674,7 @@ export class TcpClient {
515
674
 
516
675
  socket.on('close', () => {
517
676
  this.log.warn('[Cync TCP] Socket closed.');
518
- if (this.socket === socket) {
519
- this.socket = null;
520
- }
677
+ this.socket = null;
521
678
  });
522
679
 
523
680
  socket.on('error', (err) => {
@@ -596,13 +753,14 @@ export class TcpClient {
596
753
  // Default payload is the raw frame
597
754
  let payload: unknown = frame;
598
755
 
599
- if (type === 0x83) {
600
- // 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) {
601
759
  const lanParsed = this.parseLanSwitchUpdate(frame);
602
760
  if (lanParsed) {
603
761
  payload = lanParsed;
604
- } else {
605
- // Fallback to legacy controller-level parsing
762
+ } else if (type === 0x83) {
763
+ // Fallback to legacy controller-level parsing only for 0x83
606
764
  const parsed = this.parseSwitchStateFrame(frame);
607
765
  if (parsed) {
608
766
  const deviceId = this.controllerToDevice.get(parsed.controllerId);