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.
- package/CHANGELOG.md +22 -0
- package/README.md +17 -2
- package/dist/cync/config-client.d.ts +0 -4
- package/dist/cync/config-client.js +0 -1
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-client.js +13 -0
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +7 -18
- package/dist/cync/tcp-client.js +117 -22
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.d.ts +4 -0
- package/dist/cync/token-store.js +10 -2
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +2 -12
- package/dist/platform.js +321 -26
- package/dist/platform.js.map +1 -1
- package/dist/platformAccessory.js +1 -0
- package/dist/platformAccessory.js.map +1 -1
- package/homebridge-ui/public/icon.png +0 -0
- package/package.json +6 -3
- package/src/cync/config-client.ts +1 -6
- package/src/cync/cync-client.ts +20 -0
- package/src/cync/tcp-client.ts +214 -23
- package/src/cync/token-store.ts +11 -2
- package/src/platform.ts +537 -34
- package/src/platformAccessory.ts +2 -0
- package/nodemon.json +0 -12
- package/src/@types/homebridge-lib.d.ts +0 -14
package/src/cync/tcp-client.ts
CHANGED
|
@@ -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
|
-
|
|
567
|
-
|
|
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);
|
package/src/cync/token-store.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|