homebridge-cync-app 0.1.7 → 0.1.9

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cync/config-client.d.ts +11 -0
  3. package/dist/cync/config-client.js +113 -6
  4. package/dist/cync/config-client.js.map +1 -1
  5. package/dist/cync/cync-accessory-helpers.d.ts +46 -0
  6. package/dist/cync/cync-accessory-helpers.js +140 -0
  7. package/dist/cync/cync-accessory-helpers.js.map +1 -0
  8. package/dist/cync/cync-client.d.ts +4 -0
  9. package/dist/cync/cync-client.js +150 -34
  10. package/dist/cync/cync-client.js.map +1 -1
  11. package/dist/cync/cync-light-accessory.d.ts +4 -0
  12. package/dist/cync/cync-light-accessory.js +190 -0
  13. package/dist/cync/cync-light-accessory.js.map +1 -0
  14. package/dist/cync/cync-switch-accessory.d.ts +4 -0
  15. package/dist/cync/cync-switch-accessory.js +64 -0
  16. package/dist/cync/cync-switch-accessory.js.map +1 -0
  17. package/dist/cync/device-catalog.js +9 -4
  18. package/dist/cync/device-catalog.js.map +1 -1
  19. package/dist/cync/tcp-client.d.ts +7 -0
  20. package/dist/cync/tcp-client.js +122 -30
  21. package/dist/cync/tcp-client.js.map +1 -1
  22. package/dist/cync/token-store.js +2 -2
  23. package/dist/cync/token-store.js.map +1 -1
  24. package/dist/platform.d.ts +1 -3
  25. package/dist/platform.js +18 -382
  26. package/dist/platform.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cync/config-client.ts +175 -12
  29. package/src/cync/cync-accessory-helpers.ts +233 -0
  30. package/src/cync/cync-client.ts +231 -44
  31. package/src/cync/cync-light-accessory.ts +369 -0
  32. package/src/cync/cync-switch-accessory.ts +119 -0
  33. package/src/cync/device-catalog.ts +9 -4
  34. package/src/cync/tcp-client.ts +153 -53
  35. package/src/cync/token-store.ts +3 -2
  36. package/src/platform.ts +49 -661
@@ -14,7 +14,11 @@ const defaultLogger: CyncLogger = {
14
14
  export type DeviceUpdateCallback = (payload: unknown) => void;
15
15
  export type RawFrameListener = (frame: Buffer) => void;
16
16
 
17
+ type TransportMode = 'tls_strict' | 'tls_relaxed' | 'tcp';
18
+
17
19
  export class TcpClient {
20
+ private transportMode: TransportMode | null = null;
21
+
18
22
  public registerSwitchMapping(controllerId: number, deviceId: string): void {
19
23
  if (!Number.isFinite(controllerId)) {
20
24
  return;
@@ -38,6 +42,10 @@ export class TcpClient {
38
42
  private heartbeatTimer: NodeJS.Timeout | null = null;
39
43
  private rawFrameListeners: RawFrameListener[] = [];
40
44
  private controllerToDevice = new Map<number, string>();
45
+ private reconnectTimer: NodeJS.Timeout | null = null;
46
+ private reconnectAttempt = 0;
47
+ private connectInFlight: Promise<void> | null = null;
48
+ private shuttingDown = false;
41
49
 
42
50
  private enqueueCommand<T>(fn: () => Promise<T>): Promise<T> {
43
51
  let resolveWrapper: (value: T | PromiseLike<T>) => void;
@@ -159,6 +167,7 @@ export class TcpClient {
159
167
  loginCode: Uint8Array,
160
168
  config: CyncCloudConfig,
161
169
  ): Promise<void> {
170
+ this.shuttingDown = false;
162
171
  this.loginCode = loginCode;
163
172
  this.config = config;
164
173
 
@@ -168,10 +177,6 @@ export class TcpClient {
168
177
  );
169
178
  return;
170
179
  }
171
-
172
- // Optional eager connect at startup; failures are logged and we rely
173
- // on ensureConnected() to reconnect on demand later.
174
- await this.ensureConnected();
175
180
  }
176
181
 
177
182
  public applyLanTopology(topology: {
@@ -201,16 +206,55 @@ export class TcpClient {
201
206
  }
202
207
 
203
208
  if (!this.loginCode || !this.loginCode.length || !this.config) {
204
- this.log.warn(
205
- '[Cync TCP] ensureConnected() called without loginCode/config; cannot open socket.',
206
- );
209
+ this.log.warn('[Cync TCP] ensureConnected() called without loginCode/config; cannot open socket.');
207
210
  return false;
208
211
  }
209
212
 
210
- await this.establishSocket();
213
+ if (this.connectInFlight) {
214
+ await this.connectInFlight;
215
+ return !!(this.socket && !this.socket.destroyed);
216
+ }
217
+
218
+ this.connectInFlight = this.establishSocket()
219
+ .finally(() => {
220
+ this.connectInFlight = null;
221
+ });
222
+
223
+ await this.connectInFlight;
211
224
  return !!(this.socket && !this.socket.destroyed);
212
225
  }
213
226
 
227
+ private scheduleReconnect(reason: string): void {
228
+ if (this.shuttingDown) {
229
+ this.log.debug('[Cync TCP] Not scheduling reconnect (shutting down): %s', reason);
230
+ return;
231
+ }
232
+
233
+ if (this.reconnectTimer) {
234
+ return;
235
+ }
236
+
237
+ if (!this.loginCode || !this.loginCode.length || !this.config) {
238
+ return;
239
+ }
240
+
241
+ const attempt = this.reconnectAttempt;
242
+ const delayMs = Math.min(30_000, 1_000 * Math.pow(2, attempt));
243
+ this.reconnectAttempt++;
244
+
245
+ this.log.debug('[Cync TCP] Scheduling reconnect in %dms (%s)', delayMs, reason);
246
+
247
+ this.reconnectTimer = setTimeout(() => {
248
+ this.reconnectTimer = null;
249
+
250
+ // Fire and forget; ensureConnected() logs failures already
251
+ void this.ensureConnected().catch((err: unknown) => {
252
+ this.log.debug('[Cync TCP] Reconnect attempt failed: %s', String(err));
253
+ this.scheduleReconnect('retry');
254
+ });
255
+ }, delayMs);
256
+ }
257
+
214
258
  private async establishSocket(): Promise<void> {
215
259
  const host = 'cm.gelighting.com';
216
260
  const portTLS = 23779;
@@ -219,34 +263,36 @@ export class TcpClient {
219
263
  this.log.info('[Cync TCP] Connecting to %s…', host);
220
264
 
221
265
  let socket: net.Socket | null = null;
222
-
266
+ if (this.socket) {
267
+ this.cleanupSocket(this.socket);
268
+ this.socket.destroy();
269
+ this.socket = null;
270
+ }
223
271
  try {
224
- // 1. Try strict TLS
225
- try {
226
- socket = await this.openTlsSocket(host, portTLS, true);
227
- } catch (e1) {
228
- this.log.warn('[Cync TCP] TLS strict failed, trying relaxed TLS…');
272
+ // If we already learned the best mode, reuse it.
273
+ if (this.transportMode === 'tls_relaxed') {
274
+ socket = await this.openTlsSocket(host, portTLS, false);
275
+ } else if (this.transportMode === 'tcp') {
276
+ socket = await this.openTcpSocket(host, portTCP);
277
+ } else {
278
+ // Default path: strict once, then downgrade and remember.
229
279
  try {
230
- socket = await this.openTlsSocket(host, portTLS, false);
231
- } catch (e2) {
232
- this.log.warn(
233
- '[Cync TCP] TLS relaxed failed, falling back to plain TCP…',
234
- );
235
- socket = await this.openTcpSocket(host, portTCP);
280
+ socket = await this.openTlsSocket(host, portTLS, true);
281
+ this.transportMode = 'tls_strict';
282
+ } catch {
283
+ this.log.debug('[Cync TCP] TLS strict failed; trying relaxed TLS…');
284
+ try {
285
+ socket = await this.openTlsSocket(host, portTLS, false);
286
+ this.transportMode = 'tls_relaxed';
287
+ } catch {
288
+ this.log.debug('[Cync TCP] TLS relaxed failed; falling back to plain TCP…');
289
+ socket = await this.openTcpSocket(host, portTCP);
290
+ this.transportMode = 'tcp';
291
+ }
236
292
  }
237
293
  }
238
294
  } catch (err) {
239
- this.log.error(
240
- '[Cync TCP] Failed to connect to %s: %s',
241
- host,
242
- String(err),
243
- );
244
- this.socket = null;
245
- return;
246
- }
247
-
248
- if (!socket) {
249
- this.log.error('[Cync TCP] Socket is null after connect attempts.');
295
+ this.log.error('[Cync TCP] Failed to connect to %s: %s', host, String(err));
250
296
  this.socket = null;
251
297
  return;
252
298
  }
@@ -254,41 +300,68 @@ export class TcpClient {
254
300
  this.socket = socket;
255
301
  this.attachSocketListeners(this.socket);
256
302
 
257
- // Send loginCode immediately, as HA does.
258
303
  if (this.loginCode && this.loginCode.length > 0) {
259
304
  this.socket.write(Buffer.from(this.loginCode));
260
- this.log.info(
261
- '[Cync TCP] Login code sent (%d bytes).',
262
- this.loginCode.length,
263
- );
305
+ this.log.info('[Cync TCP] Login code sent (%d bytes).', this.loginCode.length);
264
306
  } else {
265
- this.log.warn(
266
- '[Cync TCP] establishSocket() reached with no loginCode; skipping auth write.',
267
- );
307
+ this.log.warn('[Cync TCP] establishSocket() reached with no loginCode; skipping auth write.');
268
308
  }
269
309
 
270
- // Start heartbeat: every 180 seconds send d3 00 00 00 00
271
310
  this.startHeartbeat();
311
+ this.reconnectAttempt = 0;
312
+ if (this.reconnectTimer) {
313
+ clearTimeout(this.reconnectTimer);
314
+ this.reconnectTimer = null;
315
+ }
316
+ }
317
+
318
+ private cleanupSocket(sock: net.Socket | null): void {
319
+ if (!sock) {
320
+ return;
321
+ }
322
+
323
+ sock.removeAllListeners('data');
324
+ sock.removeAllListeners('close');
325
+ sock.removeAllListeners('error');
326
+
327
+ // Note: caller decides whether to destroy()
272
328
  }
273
329
 
274
330
  private openTlsSocket(host: string, port: number, strict: boolean): Promise<net.Socket> {
275
331
  return new Promise((resolve, reject) => {
276
- const sock = tls.connect(
277
- {
278
- host,
279
- port,
280
- rejectUnauthorized: strict,
281
- },
282
- () => resolve(sock),
283
- );
284
- sock.once('error', reject);
332
+ const sock = tls.connect({ host, port, rejectUnauthorized: strict });
333
+
334
+ const onError = (err: Error) => {
335
+ // 'secureConnect' is registered with once(); no need to remove it here.
336
+ reject(err);
337
+ };
338
+
339
+ const onSecure = () => {
340
+ sock.removeListener('error', onError);
341
+ resolve(sock);
342
+ };
343
+
344
+ sock.once('error', onError);
345
+ sock.once('secureConnect', onSecure);
285
346
  });
286
347
  }
287
348
 
288
349
  private openTcpSocket(host: string, port: number): Promise<net.Socket> {
289
350
  return new Promise((resolve, reject) => {
290
- const sock = net.createConnection({ host, port }, () => resolve(sock));
291
- sock.once('error', reject);
351
+ const sock = net.createConnection({ host, port });
352
+
353
+ const onError = (err: Error) => {
354
+ // 'connect' is registered with once(); no need to remove it here.
355
+ reject(err);
356
+ };
357
+
358
+ const onConnect = () => {
359
+ sock.removeListener('error', onError);
360
+ resolve(sock);
361
+ };
362
+
363
+ sock.once('error', onError);
364
+ sock.once('connect', onConnect);
292
365
  });
293
366
  }
294
367
 
@@ -418,11 +491,21 @@ export class TcpClient {
418
491
  }
419
492
  public async disconnect(): Promise<void> {
420
493
  this.log.info('[Cync TCP] disconnect() called.');
494
+ this.shuttingDown = true;
495
+
496
+ if (this.reconnectTimer) {
497
+ clearTimeout(this.reconnectTimer);
498
+ this.reconnectTimer = null;
499
+ }
500
+ this.reconnectAttempt = 0;
501
+
421
502
  if (this.heartbeatTimer) {
422
503
  clearInterval(this.heartbeatTimer);
423
504
  this.heartbeatTimer = null;
424
505
  }
506
+
425
507
  if (this.socket) {
508
+ this.cleanupSocket(this.socket);
426
509
  this.socket.destroy();
427
510
  this.socket = null;
428
511
  }
@@ -674,7 +757,23 @@ export class TcpClient {
674
757
 
675
758
  socket.on('close', () => {
676
759
  this.log.warn('[Cync TCP] Socket closed.');
677
- this.socket = null;
760
+
761
+ if (this.heartbeatTimer) {
762
+ clearInterval(this.heartbeatTimer);
763
+ this.heartbeatTimer = null;
764
+ }
765
+
766
+ this.cleanupSocket(socket);
767
+ if (this.socket === socket) {
768
+ this.socket = null;
769
+ }
770
+
771
+ this.reconnectAttempt = 0;
772
+
773
+ if (this.reconnectTimer) {
774
+ clearTimeout(this.reconnectTimer);
775
+ this.reconnectTimer = null;
776
+ }
678
777
  });
679
778
 
680
779
  socket.on('error', (err) => {
@@ -682,6 +781,7 @@ export class TcpClient {
682
781
  });
683
782
  }
684
783
 
784
+
685
785
  private processIncoming(): void {
686
786
  while (this.readBuffer.length >= 5) {
687
787
  const type = this.readBuffer.readUInt8(0);
@@ -736,7 +836,7 @@ export class TcpClient {
736
836
  );
737
837
  }
738
838
 
739
- // ### 🧩 Incoming Frame Handler: routes LAN messages to raw + parsed callbacks
839
+ // Incoming Frame Handler: routes LAN messages to raw + parsed callbacks
740
840
  private handleIncomingFrame(frame: Buffer, type: number): void {
741
841
  // Fan out raw frame to higher layers (CyncClient) for debugging
742
842
  for (const listener of this.rawFrameListeners) {
@@ -31,8 +31,9 @@ export class CyncTokenStore {
31
31
  const raw = await fs.readFile(this.filePath, 'utf8');
32
32
  const data = JSON.parse(raw) as CyncTokenData;
33
33
 
34
- // If expiresAt is set and in the past, treat as invalid
35
- if (data.expiresAt && data.expiresAt <= Date.now()) {
34
+ const now = Date.now();
35
+
36
+ if (typeof data.expiresAt === 'number' && data.expiresAt <= now) {
36
37
  return null;
37
38
  }
38
39