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.
@@ -36,9 +36,37 @@ export class CyncClient {
36
36
  private session: CyncLoginSession | null = null;
37
37
  private cloudConfig: CyncCloudConfig | null = null;
38
38
 
39
+ // ### 🧩 LAN Topology Cache: mirrors HA's home_devices / home_controllers / switchID_to_homeID
40
+ private homeDevices: Record<string, string[]> = {};
41
+ private homeControllers: Record<string, number[]> = {};
42
+ private switchIdToHomeId: Record<number, string> = {};
43
+
39
44
  // Credentials from config.json, used to drive 2FA bootstrap.
40
45
  private readonly loginConfig: { email: string; password: string; twoFactor?: string };
41
46
 
47
+ // Optional LAN update hook for the platform
48
+ private lanUpdateHandler: ((update: unknown) => void) | null = null;
49
+
50
+ // ### 🧩 LAN Update Bridge: allow platform to handle device updates
51
+ public onLanDeviceUpdate(handler: (update: unknown) => void): void {
52
+ this.lanUpdateHandler = handler;
53
+ }
54
+
55
+ // ### 🧩 LAN Auth Blob Getter: Returns the LAN login code if available
56
+ public getLanLoginCode(): Uint8Array {
57
+ if (!this.tokenData?.lanLoginCode) {
58
+ this.log.debug('CyncClient: getLanLoginCode() → no LAN blob in token store.');
59
+ return new Uint8Array();
60
+ }
61
+
62
+ try {
63
+ return Uint8Array.from(Buffer.from(this.tokenData.lanLoginCode, 'base64'));
64
+ } catch {
65
+ this.log.warn('CyncClient: stored LAN login code is invalid base64.');
66
+ return new Uint8Array();
67
+ }
68
+ }
69
+
42
70
  constructor(
43
71
  configClient: ConfigClient,
44
72
  tcpClient: TcpClient,
@@ -53,6 +81,32 @@ export class CyncClient {
53
81
  this.loginConfig = loginConfig;
54
82
  this.tokenStore = new CyncTokenStore(storagePath);
55
83
  }
84
+ // ### 🧩 LAN Login Code Builder
85
+ private buildLanLoginCode(authorize: string, userId: number): Uint8Array {
86
+ const authorizeBytes = Buffer.from(authorize, 'ascii');
87
+
88
+ const head = Buffer.from('13000000', 'hex');
89
+ const lengthByte = Buffer.from([10 + authorizeBytes.length]);
90
+ const tag = Buffer.from('03', 'hex');
91
+
92
+ const userIdBytes = Buffer.alloc(4);
93
+ userIdBytes.writeUInt32BE(userId);
94
+
95
+ const authLenBytes = Buffer.alloc(2);
96
+ authLenBytes.writeUInt16BE(authorizeBytes.length);
97
+
98
+ const tail = Buffer.from('0000b4', 'hex');
99
+
100
+ return Buffer.concat([
101
+ head,
102
+ lengthByte,
103
+ tag,
104
+ userIdBytes,
105
+ authLenBytes,
106
+ authorizeBytes,
107
+ tail,
108
+ ]);
109
+ }
56
110
 
57
111
  /**
58
112
  * Ensure we are logged in:
@@ -104,11 +158,30 @@ export class CyncClient {
104
158
  String(twoFactor).trim(),
105
159
  );
106
160
 
161
+ // Build LAN login code
162
+ let authorize: string | undefined;
163
+ let lanLoginCode: string | undefined;
164
+
165
+ const raw = loginResult.raw as Record<string, unknown>;
166
+ if (typeof raw.authorize === 'string') {
167
+ authorize = raw.authorize;
168
+
169
+ const lanBlob = this.buildLanLoginCode(authorize, Number(loginResult.userId));
170
+ lanLoginCode = Buffer.from(lanBlob).toString('base64');
171
+ } else {
172
+ this.log.warn(
173
+ 'CyncClient: login response missing "authorize"; LAN login will be disabled.',
174
+ );
175
+ }
176
+
107
177
  const tokenData: CyncTokenData = {
108
178
  userId: String(loginResult.userId),
109
179
  accessToken: loginResult.accessToken,
110
180
  refreshToken: loginResult.refreshToken,
111
181
  expiresAt: loginResult.expiresAt ?? undefined,
182
+
183
+ authorize,
184
+ lanLoginCode,
112
185
  };
113
186
 
114
187
  await this.tokenStore.save(tokenData);
@@ -145,6 +218,13 @@ export class CyncClient {
145
218
  }
146
219
  > {
147
220
  const session = await this.submitTwoFactor(email, password, code);
221
+ // Extract authorize field from session.raw (Cync returns it)
222
+ const raw = session.raw as Record<string, unknown>;
223
+ const authorize = typeof raw?.authorize === 'string' ? raw.authorize : undefined;
224
+
225
+ if (!authorize) {
226
+ throw new Error('CyncClient: missing "authorize" field from login response; LAN login cannot be generated.');
227
+ }
148
228
 
149
229
  const s = session as unknown as SessionWithPossibleTokens;
150
230
 
@@ -158,6 +238,7 @@ export class CyncClient {
158
238
  accessToken: access,
159
239
  refreshToken: s.refreshToken ?? s.refreshJwt,
160
240
  expiresAt: s.expiresAt,
241
+ authorize,
161
242
  };
162
243
  }
163
244
 
@@ -190,6 +271,13 @@ export class CyncClient {
190
271
  expiresAt: tokenData.expiresAt,
191
272
  },
192
273
  };
274
+ // Restore LAN auth blob into memory
275
+ if (tokenData.authorize && tokenData.lanLoginCode) {
276
+ this.log.debug('CyncClient: LAN login code restored from token store.');
277
+ // nothing else needed — getLanLoginCode() will use it
278
+ } else {
279
+ this.log.debug('CyncClient: token store missing LAN login fields.');
280
+ }
193
281
 
194
282
  this.log.debug(
195
283
  'CyncClient: access token applied from %s; userId=%s, expiresAt=%s',
@@ -262,6 +350,10 @@ export class CyncClient {
262
350
 
263
351
  /**
264
352
  * Fetch and cache the cloud configuration (meshes/devices) for the logged-in user.
353
+ * Also builds HA-style LAN topology mappings:
354
+ * - homeDevices[homeId][meshIndex] -> deviceId
355
+ * - homeControllers[homeId] -> controllerIds[]
356
+ * - switchIdToHomeId[controllerId] -> homeId
265
357
  */
266
358
  public async loadConfiguration(): Promise<CyncCloudConfig> {
267
359
  this.ensureSession();
@@ -269,9 +361,16 @@ export class CyncClient {
269
361
  this.log.info('CyncClient: loading Cync cloud configuration…');
270
362
  const cfg = await this.configClient.getCloudConfig();
271
363
 
364
+ // Reset LAN topology caches on each reload
365
+ this.homeDevices = {};
366
+ this.homeControllers = {};
367
+ this.switchIdToHomeId = {};
368
+
272
369
  // Debug: inspect per-mesh properties so we can find the real devices.
273
370
  for (const mesh of cfg.meshes) {
274
371
  const meshName = mesh.name ?? mesh.id;
372
+ const homeId = String(mesh.id);
373
+
275
374
  this.log.debug(
276
375
  'CyncClient: probing properties for mesh %s (id=%s, product_id=%s)',
277
376
  meshName,
@@ -279,6 +378,10 @@ export class CyncClient {
279
378
  mesh.product_id,
280
379
  );
281
380
 
381
+ // Per-home maps, mirroring HA's CyncUserData.get_cync_config()
382
+ const homeDevices: string[] = [];
383
+ const homeControllers: number[] = [];
384
+
282
385
  try {
283
386
  const props = await this.configClient.getDeviceProperties(
284
387
  mesh.product_id,
@@ -293,6 +396,7 @@ export class CyncClient {
293
396
 
294
397
  type DeviceProps = Record<string, unknown>;
295
398
  const bulbsArray = (props as DeviceProps).bulbsArray as unknown;
399
+
296
400
  if (Array.isArray(bulbsArray)) {
297
401
  this.log.info(
298
402
  'CyncClient: mesh %s bulbsArray length=%d; first item keys=%o',
@@ -302,32 +406,105 @@ export class CyncClient {
302
406
  );
303
407
 
304
408
  type RawDevice = Record<string, unknown>;
305
-
306
409
  const rawDevices = bulbsArray as unknown[];
307
410
 
308
- (mesh as Record<string, unknown>).devices = rawDevices.map((raw: unknown) => {
411
+ const devicesForMesh: unknown[] = [];
412
+
413
+ for (const raw of rawDevices) {
309
414
  const d = raw as RawDevice;
310
415
 
311
416
  const displayName = d.displayName as string | undefined;
312
- const deviceID = (d.deviceID ?? d.deviceId) as string | undefined;
417
+
418
+ // deviceID can be number or string – normalize to string
419
+ const deviceIdRaw = (d.deviceID ?? d.deviceId) as string | number | undefined;
420
+ const deviceIdStr =
421
+ deviceIdRaw !== undefined && deviceIdRaw !== null
422
+ ? String(deviceIdRaw)
423
+ : undefined;
424
+
313
425
  const wifiMac = d.wifiMac as string | undefined;
314
- const productId = (d.product_id as string | undefined) ?? mesh.product_id;
426
+ const productId =
427
+ (d.product_id as string | undefined) ?? mesh.product_id;
428
+
429
+ // Reproduce HA's mesh index calculation:
430
+ // current_index = ((deviceID % home_id) % 1000) + (int((deviceID % home_id) / 1000) * 256)
431
+ const homeIdNum = Number(mesh.id);
432
+ const deviceIdNum =
433
+ typeof deviceIdRaw === 'number'
434
+ ? deviceIdRaw
435
+ : deviceIdRaw !== undefined && deviceIdRaw !== null
436
+ ? Number(deviceIdRaw)
437
+ : NaN;
438
+
439
+ let meshIndex: number | undefined;
440
+ if (!Number.isNaN(homeIdNum) && !Number.isNaN(deviceIdNum) && homeIdNum !== 0) {
441
+ const mod = deviceIdNum % homeIdNum;
442
+ meshIndex = (mod % 1000) + Math.floor(mod / 1000) * 256;
443
+ }
444
+
445
+ // Controller ID used by LAN packets (HA's switch_controller)
446
+ const switchController = d.switchID as number | undefined;
315
447
 
316
448
  // Use deviceID first, then wifiMac (stripped), then a mesh-based fallback.
317
449
  const id =
318
- deviceID ??
450
+ deviceIdStr ??
319
451
  (wifiMac ? wifiMac.replace(/:/g, '') : undefined) ??
320
452
  `${mesh.id}-${productId ?? 'unknown'}`;
321
453
 
322
- return {
454
+ // Mirror HA's home_devices[homeId][meshIndex] = deviceId
455
+ if (meshIndex !== undefined && deviceIdStr) {
456
+ while (homeDevices.length <= meshIndex) {
457
+ homeDevices.push('');
458
+ }
459
+ homeDevices[meshIndex] = deviceIdStr;
460
+ }
461
+
462
+ // Mirror HA's switchID_to_homeID + home_controllers
463
+ if (switchController !== undefined && Number.isFinite(switchController) && switchController > 0) {
464
+ if (!this.switchIdToHomeId[switchController]) {
465
+ this.switchIdToHomeId[switchController] = homeId;
466
+ }
467
+ if (!homeControllers.includes(switchController)) {
468
+ homeControllers.push(switchController);
469
+ }
470
+ }
471
+
472
+ devicesForMesh.push({
323
473
  id,
324
474
  name: displayName ?? undefined,
325
475
  product_id: productId,
326
- device_id: deviceID,
476
+ device_id: deviceIdStr,
327
477
  mac: wifiMac,
478
+ mesh_id: meshIndex,
479
+ switch_controller: switchController,
328
480
  raw: d,
329
- };
330
- });
481
+ });
482
+ }
483
+
484
+ // Attach per-mesh devices to the cloud config (what platform.ts already uses)
485
+ (mesh as Record<string, unknown>).devices = devicesForMesh;
486
+
487
+ // Persist per-home topology maps for TCP parsing later
488
+ if (homeDevices.length > 0) {
489
+ this.homeDevices[homeId] = homeDevices;
490
+ }
491
+ if (homeControllers.length > 0) {
492
+ this.homeControllers[homeId] = homeControllers;
493
+ }
494
+
495
+ // Maintain the legacy controller→device mapping for now so existing TCP code keeps working.
496
+ for (const dev of devicesForMesh) {
497
+ const record = dev as Record<string, unknown>;
498
+ const controllerId = record.switch_controller as number | undefined;
499
+
500
+ const deviceId =
501
+ (record.device_id as string | undefined) ??
502
+ (record.id as string | undefined);
503
+
504
+ if (controllerId !== undefined && deviceId) {
505
+ this.tcpClient.registerSwitchMapping(controllerId, deviceId);
506
+ }
507
+ }
331
508
  } else {
332
509
  this.log.info(
333
510
  'CyncClient: mesh %s has no bulbsArray in properties; props keys=%o',
@@ -354,40 +531,37 @@ export class CyncClient {
354
531
  return cfg;
355
532
  }
356
533
 
357
- /**
358
- * Start the LAN/TCP transport (stub for now).
359
- */
360
534
  public async startTransport(
361
535
  config: CyncCloudConfig,
362
536
  loginCode: Uint8Array,
363
537
  ): Promise<void> {
364
538
  this.ensureSession();
365
- this.log.info('CyncClient: starting TCP transport (stub)…');
366
-
367
- await this.tcpClient.connect(loginCode, config);
368
- }
369
-
370
- public async stopTransport(): Promise<void> {
371
- this.log.info('CyncClient: stopping TCP transport…');
372
- await this.tcpClient.disconnect();
373
- }
539
+ this.log.info('CyncClient: starting TCP transport…');
374
540
 
375
- /**
376
- * High-level helper for toggling a switch/plug.
377
- */
378
- public async setSwitchState(
379
- deviceId: string,
380
- params: { on: boolean; [key: string]: unknown },
381
- ): Promise<void> {
382
- this.ensureSession();
541
+ // Push current LAN topology (built in loadConfiguration) into the TCP client
542
+ const topology = this.getLanTopology();
543
+ this.tcpClient.applyLanTopology(topology);
383
544
 
384
- this.log.debug(
385
- 'CyncClient: setSwitchState stub; deviceId=%s params=%o',
386
- deviceId,
387
- params,
388
- );
545
+ // Optional: dump all frames as hex for debugging
546
+ this.tcpClient.onRawFrame((frame) => {
547
+ this.log.debug(
548
+ '[Cync TCP] raw frame (%d bytes): %s',
549
+ frame.byteLength,
550
+ frame.toString('hex'),
551
+ );
552
+ });
553
+
554
+ // REQUIRED: subscribe to parsed device updates
555
+ this.tcpClient.onDeviceUpdate((update) => {
556
+ if (this.lanUpdateHandler) {
557
+ this.lanUpdateHandler(update);
558
+ } else {
559
+ // Fallback: log only
560
+ this.log.info('[Cync TCP] device update callback fired; payload=%o', update);
561
+ }
562
+ });
389
563
 
390
- await this.tcpClient.setSwitchState(deviceId, params);
564
+ await this.tcpClient.connect(loginCode, config);
391
565
  }
392
566
 
393
567
  public getSessionSnapshot(): CyncLoginSession | null {
@@ -398,6 +572,18 @@ export class CyncClient {
398
572
  return this.cloudConfig;
399
573
  }
400
574
 
575
+ public getLanTopology(): {
576
+ homeDevices: Record<string, string[]>;
577
+ homeControllers: Record<string, number[]>;
578
+ switchIdToHomeId: Record<number, string>;
579
+ } {
580
+ return {
581
+ homeDevices: this.homeDevices,
582
+ homeControllers: this.homeControllers,
583
+ switchIdToHomeId: this.switchIdToHomeId,
584
+ };
585
+ }
586
+
401
587
  private ensureSession(): void {
402
588
  if (!this.session) {
403
589
  throw new Error(