homebridge-cync-app 0.1.2 → 0.1.5

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,38 @@ 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
+ * ### 🧩 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
+ */
47
+ private enqueueCommand<T>(fn: () => Promise<T>): Promise<T> {
48
+ let resolveWrapper: (value: T | PromiseLike<T>) => void;
49
+ let rejectWrapper: (reason?: unknown) => void;
50
+
51
+ const p = new Promise<T>((resolve, reject) => {
52
+ resolveWrapper = resolve;
53
+ rejectWrapper = reject;
54
+ });
55
+
56
+ // Chain onto the existing promise
57
+ this.commandChain = this.commandChain
58
+ .then(async () => {
59
+ try {
60
+ const result = await fn();
61
+ resolveWrapper(result);
62
+ } catch (err) {
63
+ rejectWrapper(err);
64
+ }
65
+ })
66
+ .catch(() => {
67
+ // Swallow errors in the chain so a failed command
68
+ // doesn't permanently block the queue.
69
+ });
70
+
71
+ return p;
72
+ }
50
73
  private parseSwitchStateFrame(frame: Buffer): { controllerId: number; on: boolean; level: number } | null {
51
74
  if (frame.length < 16) {
52
75
  return null;
@@ -180,8 +203,7 @@ export class TcpClient {
180
203
 
181
204
  /**
182
205
  * 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`.
206
+ * If the socket is closed or missing, attempt to reconnect.
185
207
  */
186
208
  private async ensureConnected(): Promise<boolean> {
187
209
  if (this.socket && !this.socket.destroyed) {
@@ -195,28 +217,8 @@ export class TcpClient {
195
217
  return false;
196
218
  }
197
219
 
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;
220
+ await this.establishSocket();
221
+ return !!(this.socket && !this.socket.destroyed);
220
222
  }
221
223
 
222
224
  /**
@@ -325,45 +327,6 @@ export class TcpClient {
325
327
  return this.seq;
326
328
  }
327
329
 
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
330
  private buildPowerPacket(
368
331
  controllerId: number,
369
332
  meshId: number,
@@ -414,9 +377,6 @@ export class TcpClient {
414
377
  this.socket.destroy();
415
378
  this.socket = null;
416
379
  }
417
- this.sendQueue.length = 0;
418
- this.sending = false;
419
- this.connecting = null;
420
380
  }
421
381
 
422
382
  public onDeviceUpdate(cb: DeviceUpdateCallback): void {
@@ -442,49 +402,58 @@ export class TcpClient {
442
402
 
443
403
  /**
444
404
  * 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.
405
+ * Ensures we have a live socket before sending and serializes commands.
447
406
  */
448
407
  public async setSwitchState(
449
408
  deviceId: string,
450
409
  params: { on: boolean },
451
410
  ): Promise<void> {
452
- if (!this.config) {
453
- this.log.warn('[Cync TCP] No config available.');
454
- return;
455
- }
411
+ return this.enqueueCommand(async () => {
412
+ if (!this.config) {
413
+ this.log.warn('[Cync TCP] No config available.');
414
+ return;
415
+ }
456
416
 
457
- const device = this.findDevice(deviceId);
458
- if (!device) {
459
- this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
460
- return;
461
- }
417
+ const connected = await this.ensureConnected();
418
+ if (!connected || !this.socket || this.socket.destroyed) {
419
+ this.log.warn(
420
+ '[Cync TCP] Cannot send, socket not ready even after reconnect attempt.',
421
+ );
422
+ return;
423
+ }
424
+
425
+ const device = this.findDevice(deviceId);
426
+ if (!device) {
427
+ this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
428
+ return;
429
+ }
462
430
 
463
- const controllerId = Number((device as Record<string, unknown>).switch_controller);
464
- const meshIndex = Number((device as Record<string, unknown>).mesh_id);
431
+ const record = device as Record<string, unknown>;
432
+ const controllerId = Number(record.switch_controller);
433
+ const meshIndex = Number(record.mesh_id);
465
434
 
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)',
469
- deviceId,
470
- (device as Record<string, unknown>).switch_controller,
471
- (device as Record<string, unknown>).mesh_id,
472
- );
473
- return;
474
- }
435
+ if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
436
+ this.log.warn(
437
+ '[Cync TCP] Device %s is missing LAN fields (switch_controller=%o mesh_id=%o)',
438
+ deviceId,
439
+ record.switch_controller,
440
+ record.mesh_id,
441
+ );
442
+ return;
443
+ }
475
444
 
476
- const seq = this.nextSeq();
477
- const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
445
+ const seq = this.nextSeq();
446
+ const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
478
447
 
479
- this.sendQueue.push({
480
- deviceId,
481
- on: params.on,
482
- seq,
483
- packet,
448
+ // At this point socket has been validated above
449
+ this.socket.write(packet);
450
+ this.log.info(
451
+ '[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
452
+ deviceId,
453
+ String(params.on),
454
+ seq,
455
+ );
484
456
  });
485
-
486
- // Async fire-and-forget; actual sends are serialized in flushQueue()
487
- void this.flushQueue();
488
457
  }
489
458
 
490
459
  private findDevice(deviceId: string) {
@@ -515,9 +484,7 @@ export class TcpClient {
515
484
 
516
485
  socket.on('close', () => {
517
486
  this.log.warn('[Cync TCP] Socket closed.');
518
- if (this.socket === socket) {
519
- this.socket = null;
520
- }
487
+ this.socket = null;
521
488
  });
522
489
 
523
490
  socket.on('error', (err) => {
package/src/platform.ts CHANGED
@@ -41,7 +41,10 @@ interface CyncAccessoryContext {
41
41
  */
42
42
  export class CyncAppPlatform implements DynamicPlatformPlugin {
43
43
  public readonly accessories: PlatformAccessory[] = [];
44
-
44
+ public configureAccessory(accessory: PlatformAccessory): void {
45
+ this.log.info('Restoring cached accessory', accessory.displayName);
46
+ this.accessories.push(accessory);
47
+ }
45
48
  private readonly log: Logger;
46
49
  private readonly api: API;
47
50
  private readonly config: PlatformConfig;
@@ -165,10 +168,26 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
165
168
  this.api = api;
166
169
 
167
170
  // Extract login config from platform config
168
- const cfg = this.config as Record<string, unknown>;
169
- const username = (cfg.username ?? cfg.email) as string | undefined;
170
- const password = cfg.password as string | undefined;
171
- const twoFactor = cfg.twoFactor as string | undefined;
171
+ const raw = this.config as Record<string, unknown>;
172
+
173
+ // Canonical config keys: username, password, twoFactor
174
+ // Accept legacy "email" as a fallback source for username, but do not write it back.
175
+ const username =
176
+ typeof raw.username === 'string'
177
+ ? raw.username
178
+ : typeof raw.email === 'string'
179
+ ? raw.email
180
+ : '';
181
+
182
+ const password =
183
+ typeof raw.password === 'string'
184
+ ? raw.password
185
+ : '';
186
+
187
+ const twoFactor =
188
+ typeof raw.twoFactor === 'string'
189
+ ? raw.twoFactor
190
+ : undefined;
172
191
 
173
192
  const cyncLogger = toCyncLogger(this.log);
174
193
  const tcpClient = new TcpClient(cyncLogger);
@@ -177,8 +196,8 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
177
196
  new ConfigClient(cyncLogger),
178
197
  tcpClient,
179
198
  {
180
- email: username ?? '',
181
- password: password ?? '',
199
+ username,
200
+ password,
182
201
  twoFactor,
183
202
  },
184
203
  this.api.user.storagePath(),
@@ -200,19 +219,21 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
200
219
  });
201
220
  }
202
221
 
203
- /**
204
- * Called when cached accessories are restored from disk.
205
- */
206
- configureAccessory(accessory: PlatformAccessory): void {
207
- this.log.info('Restoring cached accessory', accessory.displayName);
208
- this.accessories.push(accessory);
209
- }
210
-
211
222
  private async loadCync(): Promise<void> {
212
223
  try {
213
- const cfg = this.config as Record<string, unknown>;
214
- const username = (cfg.username ?? cfg.email) as string | undefined;
215
- const password = cfg.password as string | undefined;
224
+ const raw = this.config as Record<string, unknown>;
225
+
226
+ const username =
227
+ typeof raw.username === 'string'
228
+ ? raw.username
229
+ : typeof raw.email === 'string'
230
+ ? raw.email
231
+ : '';
232
+
233
+ const password =
234
+ typeof raw.password === 'string'
235
+ ? raw.password
236
+ : '';
216
237
 
217
238
  if (!username || !password) {
218
239
  this.log.warn('Cync: credentials missing in config.json; skipping cloud login.');
@@ -237,7 +258,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
237
258
  );
238
259
 
239
260
  // Ask the CyncClient for the LAN login code derived from stored session.
240
- // If it returns an empty blob, LAN is disabled but cloud still works.
241
261
  let loginCode: Uint8Array = new Uint8Array();
242
262
  try {
243
263
  loginCode = this.client.getLanLoginCode();
@@ -254,7 +274,6 @@ export class CyncAppPlatform implements DynamicPlatformPlugin {
254
274
  loginCode.length,
255
275
  );
256
276
 
257
- // ### 🧩 LAN Transport Bootstrap: wire frame listeners via CyncClient
258
277
  await this.client.startTransport(cloudConfig, loginCode);
259
278
  } else {
260
279
  this.log.info(