homebridge-cync-app 0.0.2 → 0.1.0

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.
@@ -1,9 +1,8 @@
1
1
  // src/cync/tcp-client.ts
2
- // Thin TCP client stub for talking to Cync WiFi devices.
3
- // The binary protocol is non-trivial; for now this class only logs calls so
4
- // that higher layers can be wired up and tested without crashing.
5
2
 
6
3
  import { CyncCloudConfig, CyncLogger } from './config-client.js';
4
+ import net from 'net';
5
+ import tls from 'tls';
7
6
 
8
7
  const defaultLogger: CyncLogger = {
9
8
  debug: (...args: unknown[]) => console.debug('[cync-tcp]', ...args),
@@ -13,14 +12,109 @@ const defaultLogger: CyncLogger = {
13
12
  };
14
13
 
15
14
  export type DeviceUpdateCallback = (payload: unknown) => void;
15
+ export type RawFrameListener = (frame: Buffer) => void;
16
16
 
17
17
  export class TcpClient {
18
+ public registerSwitchMapping(controllerId: number, deviceId: string): void {
19
+ if (!Number.isFinite(controllerId)) {
20
+ return;
21
+ }
22
+ this.controllerToDevice.set(controllerId, deviceId);
23
+ }
24
+ private homeDevices: Record<string, string[]> = {};
25
+ private switchIdToHomeId = new Map<number, string>();
18
26
  private readonly log: CyncLogger;
19
-
27
+ private loginCode: Uint8Array | null = null;
28
+ private config: CyncCloudConfig | null = null;
29
+ private meshSockets = new Map<string, net.Socket>();
20
30
  private deviceUpdateCb: DeviceUpdateCallback | null = null;
21
31
  private roomUpdateCb: DeviceUpdateCallback | null = null;
22
32
  private motionUpdateCb: DeviceUpdateCallback | null = null;
23
33
  private ambientUpdateCb: DeviceUpdateCallback | null = null;
34
+ private socket: net.Socket | null = null;
35
+ private seq = 0;
36
+ private readBuffer = Buffer.alloc(0);
37
+ private heartbeatTimer: NodeJS.Timeout | null = null;
38
+ private rawFrameListeners: RawFrameListener[] = [];
39
+ private controllerToDevice = new Map<number, string>();
40
+ private parseSwitchStateFrame(frame: Buffer): { controllerId: number; on: boolean; level: number } | null {
41
+ if (frame.length < 16) {
42
+ return null;
43
+ }
44
+
45
+ // First 4 bytes are the controller ID (big-endian)
46
+ const controllerId = frame.readUInt32BE(0);
47
+
48
+ // Look for the marker sequence db 11 02 01
49
+ const marker = Buffer.from('db110201', 'hex');
50
+ const idx = frame.indexOf(marker);
51
+
52
+ if (idx === -1) {
53
+ return null;
54
+ }
55
+
56
+ // We need at least two bytes following the marker: onFlag + level
57
+ const onIndex = idx + marker.length;
58
+ const levelIndex = onIndex + 1;
59
+
60
+ if (levelIndex >= frame.length) {
61
+ return null;
62
+ }
63
+
64
+ const onFlag = frame[onIndex];
65
+ const level = frame[levelIndex];
66
+
67
+ const on = onFlag === 0x01 && level > 0;
68
+
69
+ return { controllerId, on, level };
70
+ }
71
+
72
+ private parseLanSwitchUpdate(frame: Buffer): {
73
+ controllerId: number;
74
+ deviceId?: string;
75
+ on: boolean;
76
+ level: number;
77
+ } | null {
78
+ // Need at least enough bytes for the HA layout:
79
+ // switch_id(4) ... type(1) ... deviceIndex(1) ... state(1) ... brightness(1)
80
+ if (frame.length < 29) {
81
+ return null;
82
+ }
83
+
84
+ const controllerId = frame.readUInt32BE(0);
85
+
86
+ const homeId = this.switchIdToHomeId.get(controllerId);
87
+ if (!homeId) {
88
+ return null;
89
+ }
90
+
91
+ const devices = this.homeDevices[homeId];
92
+ if (!devices || devices.length === 0) {
93
+ return null;
94
+ }
95
+
96
+ // HA checks: packet_length >= 33 and packet[13] == 219 (0xdb)
97
+ const typeByte = frame[13];
98
+ if (typeByte !== 0xdb) {
99
+ return null;
100
+ }
101
+
102
+ const deviceIndex = frame[21];
103
+ const stateByte = frame[27];
104
+ const levelByte = frame[28];
105
+
106
+ const on = stateByte > 0;
107
+ const level = on ? levelByte : 0;
108
+
109
+ const deviceId = deviceIndex < devices.length ? devices[deviceIndex] : undefined;
110
+
111
+ return {
112
+ controllerId,
113
+ deviceId,
114
+ on,
115
+ level,
116
+ };
117
+ }
24
118
 
25
119
  constructor(logger?: CyncLogger) {
26
120
  this.log = logger ?? defaultLogger;
@@ -29,28 +123,231 @@ export class TcpClient {
29
123
  /**
30
124
  * Establish a TCP session to one or more Cync devices.
31
125
  *
32
- * In the full implementation, loginCode will be the authentication blob used
33
- * by the LAN devices, and config will contain the mesh/network information
34
- * needed to discover and connect to the correct hosts.
35
- *
36
- * For now this is a no-op that simply logs the request.
126
+ * For Homebridge:
127
+ * - We cache loginCode + config here.
128
+ * - Actual socket creation happens in ensureConnected()/establishSocket().
37
129
  */
38
130
  public async connect(
39
131
  loginCode: Uint8Array,
40
132
  config: CyncCloudConfig,
41
133
  ): Promise<void> {
134
+ this.loginCode = loginCode;
135
+ this.config = config;
136
+
137
+ if (!loginCode.length) {
138
+ this.log.warn(
139
+ '[Cync TCP] connect() called with empty loginCode; LAN control will remain disabled.',
140
+ );
141
+ return;
142
+ }
143
+
144
+ // Optional eager connect at startup; failures are logged and we rely
145
+ // on ensureConnected() to reconnect on demand later.
146
+ await this.ensureConnected();
147
+ }
148
+
149
+
150
+ public applyLanTopology(topology: {
151
+ homeDevices: Record<string, string[]>;
152
+ switchIdToHomeId: Record<number, string>;
153
+ }): void {
154
+ this.homeDevices = topology.homeDevices ?? {};
155
+
156
+ this.switchIdToHomeId = new Map<number, string>();
157
+ for (const [key, homeId] of Object.entries(topology.switchIdToHomeId ?? {})) {
158
+ const num = Number(key);
159
+ if (Number.isFinite(num)) {
160
+ this.switchIdToHomeId.set(num, homeId);
161
+ }
162
+ }
163
+
42
164
  this.log.info(
43
- 'TcpClient.connect() stub called with loginCode length=%d meshes=%d',
44
- loginCode.length,
45
- config.meshes.length,
165
+ '[Cync TCP] LAN topology applied: homes=%d controllers=%d',
166
+ Object.keys(this.homeDevices).length,
167
+ this.switchIdToHomeId.size,
46
168
  );
47
169
  }
48
170
 
171
+ /**
172
+ * Ensure we have an open, logged-in socket.
173
+ * If the socket is closed or missing, attempt to reconnect.
174
+ */
175
+ private async ensureConnected(): Promise<boolean> {
176
+ if (this.socket && !this.socket.destroyed) {
177
+ return true;
178
+ }
179
+
180
+ if (!this.loginCode || !this.loginCode.length || !this.config) {
181
+ this.log.warn(
182
+ '[Cync TCP] ensureConnected() called without loginCode/config; cannot open socket.',
183
+ );
184
+ return false;
185
+ }
186
+
187
+ await this.establishSocket();
188
+ return !!(this.socket && !this.socket.destroyed);
189
+ }
190
+
191
+ /**
192
+ * Open a new socket to cm.gelighting.com and send the loginCode,
193
+ * mirroring the HA integration’s behavior.
194
+ */
195
+ private async establishSocket(): Promise<void> {
196
+ const host = 'cm.gelighting.com';
197
+ const portTLS = 23779;
198
+ const portTCP = 23778;
199
+
200
+ this.log.info('[Cync TCP] Connecting to %s…', host);
201
+
202
+ let socket: net.Socket | null = null;
203
+
204
+ try {
205
+ // 1. Try strict TLS
206
+ try {
207
+ socket = await this.openTlsSocket(host, portTLS, true);
208
+ } catch (e1) {
209
+ this.log.warn('[Cync TCP] TLS strict failed, trying relaxed TLS…');
210
+ try {
211
+ socket = await this.openTlsSocket(host, portTLS, false);
212
+ } catch (e2) {
213
+ this.log.warn(
214
+ '[Cync TCP] TLS relaxed failed, falling back to plain TCP…',
215
+ );
216
+ socket = await this.openTcpSocket(host, portTCP);
217
+ }
218
+ }
219
+ } catch (err) {
220
+ this.log.error(
221
+ '[Cync TCP] Failed to connect to %s: %s',
222
+ host,
223
+ String(err),
224
+ );
225
+ this.socket = null;
226
+ return;
227
+ }
228
+
229
+ if (!socket) {
230
+ this.log.error('[Cync TCP] Socket is null after connect attempts.');
231
+ this.socket = null;
232
+ return;
233
+ }
234
+
235
+ this.socket = socket;
236
+ this.attachSocketListeners(this.socket);
237
+
238
+ // Send loginCode immediately, as HA does.
239
+ if (this.loginCode && this.loginCode.length > 0) {
240
+ this.socket.write(Buffer.from(this.loginCode));
241
+ this.log.info(
242
+ '[Cync TCP] Login code sent (%d bytes).',
243
+ this.loginCode.length,
244
+ );
245
+ } else {
246
+ this.log.warn(
247
+ '[Cync TCP] establishSocket() reached with no loginCode; skipping auth write.',
248
+ );
249
+ }
250
+
251
+ // Start heartbeat: every 180 seconds send d3 00 00 00 00
252
+ this.startHeartbeat();
253
+ }
254
+
255
+ private openTlsSocket(host: string, port: number, strict: boolean): Promise<net.Socket> {
256
+ return new Promise((resolve, reject) => {
257
+ const sock = tls.connect(
258
+ {
259
+ host,
260
+ port,
261
+ rejectUnauthorized: strict,
262
+ },
263
+ () => resolve(sock),
264
+ );
265
+ sock.once('error', reject);
266
+ });
267
+ }
268
+
269
+ private openTcpSocket(host: string, port: number): Promise<net.Socket> {
270
+ return new Promise((resolve, reject) => {
271
+ const sock = net.createConnection({ host, port }, () => resolve(sock));
272
+ sock.once('error', reject);
273
+ });
274
+ }
275
+
276
+ private startHeartbeat(): void {
277
+ if (this.heartbeatTimer) {
278
+ clearInterval(this.heartbeatTimer);
279
+ }
280
+ this.heartbeatTimer = setInterval(() => {
281
+ if (!this.socket || this.socket.destroyed) {
282
+ return;
283
+ }
284
+ this.socket.write(Buffer.from('d300000000', 'hex'));
285
+ }, 180_000);
286
+ }
287
+
288
+ private nextSeq(): number {
289
+ if (this.seq === 65535) {
290
+ this.seq = 1;
291
+ } else {
292
+ this.seq++;
293
+ }
294
+ return this.seq;
295
+ }
296
+
297
+ private buildPowerPacket(
298
+ controllerId: number,
299
+ meshId: number,
300
+ on: boolean,
301
+ seq: number,
302
+ ): Buffer {
303
+ const header = Buffer.from('730000001f', 'hex');
304
+
305
+ const switchBytes = Buffer.alloc(4);
306
+ switchBytes.writeUInt32BE(controllerId, 0);
307
+
308
+ const seqBytes = Buffer.alloc(2);
309
+ seqBytes.writeUInt16BE(seq, 0);
310
+
311
+ const middle = Buffer.from('007e00000000f8d00d000000000000', 'hex');
312
+
313
+ const meshBytes = Buffer.alloc(2);
314
+ meshBytes.writeUInt16LE(meshId, 0);
315
+
316
+ const tail = Buffer.from(on ? 'd00000010000' : 'd00000000000', 'hex');
317
+
318
+ const checksumSeed = on ? 430 : 429;
319
+ const checksumByte =
320
+ (checksumSeed + meshBytes[0] + meshBytes[1]) & 0xff;
321
+ const checksum = Buffer.from([checksumByte]);
322
+
323
+ const end = Buffer.from('7e', 'hex');
324
+
325
+ return Buffer.concat([
326
+ header,
327
+ switchBytes,
328
+ seqBytes,
329
+ middle,
330
+ meshBytes,
331
+ tail,
332
+ checksum,
333
+ end,
334
+ ]);
335
+ }
336
+
49
337
  public async disconnect(): Promise<void> {
50
- this.log.info('TcpClient.disconnect() stub called.');
338
+ this.log.info('[Cync TCP] disconnect() called.');
339
+ if (this.heartbeatTimer) {
340
+ clearInterval(this.heartbeatTimer);
341
+ this.heartbeatTimer = null;
342
+ }
343
+ if (this.socket) {
344
+ this.socket.destroy();
345
+ this.socket = null;
346
+ }
51
347
  }
52
348
 
53
349
  public onDeviceUpdate(cb: DeviceUpdateCallback): void {
350
+ this.log.info('[Cync TCP] device update subscriber registered.');
54
351
  this.deviceUpdateCb = cb;
55
352
  }
56
353
 
@@ -66,23 +363,193 @@ export class TcpClient {
66
363
  this.ambientUpdateCb = cb;
67
364
  }
68
365
 
366
+ public onRawFrame(listener: RawFrameListener): void {
367
+ this.rawFrameListeners.push(listener);
368
+ }
369
+
69
370
  /**
70
- * High-level API to change switch state. The actual encoding and TCP send
71
- * will be filled in once the LAN protocol is implemented.
371
+ * High-level API to change switch state.
372
+ * Ensures we have a live socket before sending.
72
373
  */
73
374
  public async setSwitchState(
74
375
  deviceId: string,
75
- params: { on: boolean; [key: string]: unknown },
376
+ params: { on: boolean },
76
377
  ): Promise<void> {
378
+ if (!this.config) {
379
+ this.log.warn('[Cync TCP] No config available.');
380
+ return;
381
+ }
382
+
383
+ const connected = await this.ensureConnected();
384
+ if (!connected || !this.socket || this.socket.destroyed) {
385
+ this.log.warn(
386
+ '[Cync TCP] Cannot send, socket not ready even after reconnect attempt.',
387
+ );
388
+ return;
389
+ }
390
+
391
+ const device = this.findDevice(deviceId);
392
+ if (!device) {
393
+ this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
394
+ return;
395
+ }
396
+
397
+ const controllerId = Number((device as Record<string, unknown>).switch_controller);
398
+ const meshIndex = Number((device as Record<string, unknown>).mesh_id);
399
+
400
+ if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
401
+ this.log.warn(
402
+ '[Cync TCP] Device %s is missing LAN fields (switch_controller=%o mesh_id=%o)',
403
+ deviceId,
404
+ (device as Record<string, unknown>).switch_controller,
405
+ (device as Record<string, unknown>).mesh_id,
406
+ );
407
+ return;
408
+ }
409
+
410
+ const seq = this.nextSeq();
411
+ const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
412
+
413
+ this.socket.write(packet);
77
414
  this.log.info(
78
- 'TcpClient.setSwitchState() stub: deviceId=%s params=%o',
415
+ '[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
79
416
  deviceId,
417
+ String(params.on),
418
+ seq,
419
+ );
420
+ }
421
+
422
+ private findDevice(deviceId: string) {
423
+ for (const mesh of this.config?.meshes ?? []) {
424
+ for (const dev of mesh.devices ?? []) {
425
+ const record = dev as Record<string, unknown>;
426
+ const devDeviceId = record.device_id !== undefined && record.device_id !== null
427
+ ? String(record.device_id)
428
+ : undefined;
429
+ const devId = record.id !== undefined && record.id !== null
430
+ ? String(record.id)
431
+ : undefined;
432
+
433
+ if (devDeviceId === deviceId || devId === deviceId) {
434
+ return dev;
435
+ }
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+
441
+ private attachSocketListeners(socket: net.Socket): void {
442
+ socket.on('data', (chunk) => {
443
+ this.log.debug('[Cync TCP] received %d bytes from server', chunk.byteLength);
444
+ this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
445
+ this.processIncoming();
446
+ });
447
+
448
+ socket.on('close', () => {
449
+ this.log.warn('[Cync TCP] Socket closed.');
450
+ this.socket = null;
451
+ });
452
+
453
+ socket.on('error', (err) => {
454
+ this.log.error('[Cync TCP] Socket error:', String(err));
455
+ });
456
+ }
457
+
458
+ private processIncoming(): void {
459
+ while (this.readBuffer.length >= 5) {
460
+ const type = this.readBuffer.readUInt8(0);
461
+ const len = this.readBuffer.readUInt32BE(1);
462
+ const total = 5 + len;
463
+
464
+ if (this.readBuffer.length < total) {
465
+ return;
466
+ }
467
+
468
+ const body = this.readBuffer.subarray(5, total);
469
+
470
+ // Debug log with full hex dump so we can reverse-engineer the protocol
471
+ this.log.debug(
472
+ '[Cync TCP] frame type=0x%s len=%d body=%s',
473
+ type.toString(16).padStart(2, '0'),
474
+ len,
475
+ body.toString('hex'),
476
+ );
477
+
478
+ if (type === 0x7b && body.length >= 6) {
479
+ const seq = body.readUInt16BE(4);
480
+ this.log.debug('[Cync TCP] ACK for seq=%d', seq);
481
+ } else {
482
+ this.handleIncomingFrame(body, type);
483
+ }
484
+
485
+ this.readBuffer = this.readBuffer.subarray(total);
486
+ }
487
+ }
488
+
489
+ private async sendRawCommand(
490
+ deviceId: string,
491
+ command: string,
492
+ params: Record<string, unknown>,
493
+ ): Promise<void> {
494
+ if (!this.config || !this.loginCode) {
495
+ this.log.warn(
496
+ 'TcpClient.sendRawCommand() called before connect(); deviceId=%s command=%s params=%o',
497
+ deviceId,
498
+ command,
499
+ params,
500
+ );
501
+ return;
502
+ }
503
+
504
+ this.log.info(
505
+ 'TcpClient.sendRawCommand() stub: deviceId=%s command=%s params=%o',
506
+ deviceId,
507
+ command,
80
508
  params,
81
509
  );
510
+ }
511
+
512
+ // ### 🧩 Incoming Frame Handler: routes LAN messages to raw + parsed callbacks
513
+ private handleIncomingFrame(frame: Buffer, type: number): void {
514
+ // Fan out raw frame to higher layers (CyncClient) for debugging
515
+ for (const listener of this.rawFrameListeners) {
516
+ try {
517
+ listener(frame);
518
+ } catch (err) {
519
+ this.log.error(
520
+ '[Cync TCP] raw frame listener threw: %s',
521
+ String(err),
522
+ );
523
+ }
524
+ }
525
+
526
+ // Default payload is the raw frame
527
+ let payload: unknown = frame;
528
+
529
+ if (type === 0x83) {
530
+ // Preferred path: HA-style per-device parsing using homeDevices + switchIdToHomeId
531
+ const lanParsed = this.parseLanSwitchUpdate(frame);
532
+ if (lanParsed) {
533
+ payload = lanParsed;
534
+ } else {
535
+ // Fallback to legacy controller-level parsing
536
+ const parsed = this.parseSwitchStateFrame(frame);
537
+ if (parsed) {
538
+ const deviceId = this.controllerToDevice.get(parsed.controllerId);
539
+ payload = {
540
+ ...parsed,
541
+ deviceId,
542
+ };
543
+ }
544
+ }
545
+ }
82
546
 
83
- // In a future implementation, this is where we would:
84
- // 1. Look up the device in the current CyncCloudConfig.
85
- // 2. Construct the appropriate binary payload.
86
- // 3. Send via a net.Socket and handle the response.
547
+ if (this.deviceUpdateCb) {
548
+ this.deviceUpdateCb(payload);
549
+ } else {
550
+ this.log.debug(
551
+ '[Cync TCP] Dropping device update frame (no subscriber).',
552
+ );
553
+ }
87
554
  }
88
555
  }
@@ -6,7 +6,9 @@ export interface CyncTokenData {
6
6
  userId: string;
7
7
  accessToken: string;
8
8
  refreshToken?: string;
9
- expiresAt?: number; // epoch ms, optional if Cync doesn't provide expiry
9
+ expiresAt?: number;
10
+ authorize?: string;
11
+ lanLoginCode?: string;
10
12
  }
11
13
 
12
14
  /**