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
@@ -28,15 +28,48 @@ export class CyncClient {
28
28
  private readonly log: CyncLogger;
29
29
  private readonly configClient: ConfigClient;
30
30
  private readonly tcpClient: TcpClient;
31
-
31
+ private readonly unsupportedPropertiesProductIds = new Set<string>();
32
32
  private readonly tokenStore: CyncTokenStore;
33
33
  private tokenData: CyncTokenData | null = null;
34
+ private isDevicePropertyNotExistsError(err: unknown): boolean {
35
+ if (!err) {
36
+ return false;
37
+ }
38
+
39
+ type ErrorWithShape = {
40
+ status?: number;
41
+ error?: {
42
+ code?: number;
43
+ msg?: string;
44
+ };
45
+ message?: string;
46
+ };
47
+
48
+ const e = err as ErrorWithShape;
34
49
 
50
+ // Observed cloud response:
51
+ // HTTP 404 Not Found { error: { msg: 'device property not exists', code: 4041009 } }
52
+ if (e.status === 404 && e.error?.code === 4041009) {
53
+ return true;
54
+ }
55
+
56
+ // Fallback (if shape changes or gets wrapped)
57
+ if (e.message && e.message.includes('device property not exists')) {
58
+ return true;
59
+ }
60
+
61
+ return false;
62
+ }
63
+
64
+ private formatMeshLabel(mesh: { name?: string | null; id: string | number }): string {
65
+ const rawName = typeof mesh.name === 'string' ? mesh.name.trim() : '';
66
+ return rawName.length > 0 ? rawName : `No Name (id=${String(mesh.id)})`;
67
+ }
35
68
  // Populated after successful login.
36
69
  private session: CyncLoginSession | null = null;
37
70
  private cloudConfig: CyncCloudConfig | null = null;
38
71
 
39
- // ### 🧩 LAN Topology Cache: mirrors HA's home_devices / home_controllers / switchID_to_homeID
72
+ // LAN Topology Cache: mirrors HA's home_devices / home_controllers / switchID_to_homeID
40
73
  private homeDevices: Record<string, string[]> = {};
41
74
  private homeControllers: Record<string, number[]> = {};
42
75
  private switchIdToHomeId: Record<number, string> = {};
@@ -52,12 +85,99 @@ export class CyncClient {
52
85
  // Optional LAN update hook for the platform
53
86
  private lanUpdateHandler: ((update: unknown) => void) | null = null;
54
87
 
55
- // ### 🧩 LAN Update Bridge: allow platform to handle device updates
88
+ // Password Login Helper: background username/password login for new tokens
89
+ private async loginWithPasswordForToken(): Promise<CyncTokenData | null> {
90
+ const { username, password } = this.loginConfig;
91
+
92
+ if (!username || !password) {
93
+ this.log.error(
94
+ 'CyncClient: cannot perform password login; username or password is missing from config.',
95
+ );
96
+ return null;
97
+ }
98
+
99
+ try {
100
+ this.log.warn(
101
+ 'CyncClient: performing background username/password login (no OTP) to obtain a fresh token…',
102
+ );
103
+
104
+ const session = await this.configClient.loginWithPassword(
105
+ username.trim(),
106
+ password,
107
+ );
108
+
109
+ const raw = session.raw as Record<string, unknown>;
110
+ const authorize =
111
+ typeof raw.authorize === 'string' ? raw.authorize : undefined;
112
+
113
+ const s = session as unknown as SessionWithPossibleTokens;
114
+ const access = s.accessToken ?? s.jwt;
115
+
116
+ if (!access) {
117
+ this.log.error(
118
+ 'CyncClient: password login session did not return an access token.',
119
+ );
120
+ return null;
121
+ }
122
+
123
+ const refresh = s.refreshToken ?? s.refreshJwt;
124
+ const expiresAt = s.expiresAt;
125
+
126
+ let lanLoginCode: string | undefined;
127
+ if (authorize) {
128
+ const userIdNum = Number.parseInt(session.userId, 10);
129
+ if (Number.isFinite(userIdNum) && userIdNum >= 0) {
130
+ const lanBlob = this.buildLanLoginCode(authorize, userIdNum);
131
+ lanLoginCode = Buffer.from(lanBlob).toString('base64');
132
+ } else {
133
+ this.log.warn(
134
+ 'CyncClient: password login returned non-numeric userId=%s; LAN login code not generated.',
135
+ session.userId,
136
+ );
137
+ }
138
+ } else {
139
+ this.log.warn(
140
+ 'CyncClient: password login response missing "authorize"; LAN login may be disabled.',
141
+ );
142
+ }
143
+
144
+ const next: CyncTokenData = {
145
+ userId: String(session.userId),
146
+ accessToken: access,
147
+ refreshToken: refresh,
148
+ expiresAt,
149
+ authorize,
150
+ lanLoginCode,
151
+ };
152
+
153
+ await this.tokenStore.save(next);
154
+ this.tokenData = next;
155
+ this.applyAccessToken(next);
156
+
157
+ this.log.info(
158
+ 'CyncClient: obtained new token via password login; userId=%s expiresAt=%s',
159
+ next.userId,
160
+ next.expiresAt
161
+ ? new Date(next.expiresAt).toISOString()
162
+ : 'unknown',
163
+ );
164
+
165
+ return next;
166
+ } catch (err) {
167
+ this.log.error(
168
+ 'CyncClient: password-based background login failed: %o',
169
+ err,
170
+ );
171
+ return null;
172
+ }
173
+ }
174
+
175
+ // LAN Update Bridge: allow platform to handle device updates
56
176
  public onLanDeviceUpdate(handler: (update: unknown) => void): void {
57
177
  this.lanUpdateHandler = handler;
58
178
  }
59
179
 
60
- // ### 🧩 LAN Auth Blob Getter: Returns the LAN login code if available
180
+ // LAN Auth Blob Getter: Returns the LAN login code if available
61
181
  public getLanLoginCode(): Uint8Array {
62
182
  if (!this.tokenData?.lanLoginCode) {
63
183
  this.log.debug('CyncClient: getLanLoginCode() → no LAN blob in token store.');
@@ -97,7 +217,7 @@ export class CyncClient {
97
217
  );
98
218
  }
99
219
 
100
- // ### 🧩 LAN Login Code Builder
220
+ // LAN Login Code Builder
101
221
  private buildLanLoginCode(authorize: string, userId: number): Uint8Array {
102
222
  const authorizeBytes = Buffer.from(authorize, 'ascii');
103
223
 
@@ -321,12 +441,22 @@ export class CyncClient {
321
441
  * // user reads email, gets code…
322
442
  * await client.submitTwoFactor(username, password, code); // completes login
323
443
  */
324
- // ### 🧩 Refresh Error Detector: identifies "Access-Token Expired" responses
444
+ // Refresh Error Detector: identifies "Access-Token Expired" responses
325
445
  private isAccessTokenExpiredError(err: unknown): boolean {
326
- if (!err || typeof err !== 'object') {
446
+ if (!err) {
327
447
  return false;
328
448
  }
329
449
 
450
+ // Most common case: ConfigClient throws a plain Error('Access-Token Expired')
451
+ if (err instanceof Error) {
452
+ if (
453
+ err.message === 'Access-Token Expired' ||
454
+ err.message.includes('Access-Token Expired')
455
+ ) {
456
+ return true;
457
+ }
458
+ }
459
+
330
460
  type ErrorWithShape = {
331
461
  status?: number;
332
462
  message?: string;
@@ -338,57 +468,82 @@ export class CyncClient {
338
468
 
339
469
  const e = err as ErrorWithShape;
340
470
 
341
- // Shape we see in logs:
471
+ // Shape we see in raw HTTP JSON:
342
472
  // { error: { msg: 'Access-Token Expired', code: 4031021 } }
343
- if (e.error && (e.error.msg === 'Access-Token Expired' || e.error.code === 4031021)) {
473
+ if (
474
+ e.error &&
475
+ (e.error.msg === 'Access-Token Expired' || e.error.code === 4031021)
476
+ ) {
344
477
  return true;
345
478
  }
346
479
 
347
- // Fallback: generic 403 with message string
348
- if (e.status === 403 && e.message && e.message.includes('Access-Token Expired')) {
480
+ // Fallback: generic 403 plus message text
481
+ if (
482
+ e.status === 403 &&
483
+ e.message &&
484
+ e.message.includes('Access-Token Expired')
485
+ ) {
349
486
  return true;
350
487
  }
351
488
 
352
489
  return false;
353
490
  }
354
491
 
355
- // ### 🧩 Token Refresh Helper: exchanges refreshToken for a new accessToken
356
- private async refreshAccessToken(stored: CyncTokenData): Promise<CyncTokenData | null> {
357
- if (!stored.refreshToken) {
492
+ // Token Refresh Helper: exchanges refreshToken for a new accessToken, or falls back to password login
493
+ private async refreshAccessToken(
494
+ stored: CyncTokenData,
495
+ ): Promise<CyncTokenData | null> {
496
+ // First, try refresh_token if we have one
497
+ if (stored.refreshToken) {
498
+ try {
499
+ const resp = await this.configClient.refreshAccessToken(
500
+ stored.refreshToken,
501
+ );
502
+
503
+ const next: CyncTokenData = {
504
+ ...stored,
505
+ accessToken: resp.accessToken,
506
+ refreshToken: resp.refreshToken ?? stored.refreshToken,
507
+ expiresAt: resp.expiresAt ?? stored.expiresAt,
508
+ };
509
+
510
+ await this.tokenStore.save(next);
511
+ this.tokenData = next;
512
+ this.applyAccessToken(next);
513
+
514
+ this.log.info(
515
+ 'CyncClient: refreshed access token for userId=%s; expiresAt=%s',
516
+ next.userId,
517
+ next.expiresAt
518
+ ? new Date(next.expiresAt).toISOString()
519
+ : 'unknown',
520
+ );
521
+
522
+ return next;
523
+ } catch (err) {
524
+ this.log.error('CyncClient: token refresh failed: %o', err);
525
+ // fall through to password-based login below
526
+ }
527
+ } else {
358
528
  this.log.warn(
359
- 'CyncClient: refreshAccessToken() called but no refreshToken is stored; cannot refresh.',
529
+ 'CyncClient: refreshAccessToken() called but no refreshToken is stored; will attempt password-based login.',
360
530
  );
361
- return null;
362
531
  }
363
532
 
364
- try {
365
- const resp = await this.configClient.refreshAccessToken(stored.refreshToken);
366
-
367
- const next: CyncTokenData = {
368
- ...stored,
369
- accessToken: resp.accessToken,
370
- refreshToken: resp.refreshToken ?? stored.refreshToken,
371
- expiresAt: resp.expiresAt ?? stored.expiresAt,
372
- };
373
-
374
- await this.tokenStore.save(next);
375
- this.tokenData = next;
376
- this.applyAccessToken(next);
377
-
378
- this.log.info(
379
- 'CyncClient: refreshed access token for userId=%s; expiresAt=%s',
380
- next.userId,
381
- next.expiresAt ? new Date(next.expiresAt).toISOString() : 'unknown',
533
+ // If we get here, either we had no refreshToken or refresh failed.
534
+ // Attempt a background username/password login to obtain a fresh token.
535
+ const viaPassword = await this.loginWithPasswordForToken();
536
+ if (!viaPassword) {
537
+ this.log.error(
538
+ 'CyncClient: password-based background login failed; cannot refresh Cync token automatically.',
382
539
  );
383
-
384
- return next;
385
- } catch (err) {
386
- this.log.error('CyncClient: token refresh failed: %o', err);
387
540
  return null;
388
541
  }
542
+
543
+ return viaPassword;
389
544
  }
390
545
 
391
- // ### 🧩 Cloud Config Wrapper: auto-refreshes access token
546
+ // Cloud Config Wrapper: auto-refreshes access token
392
547
  private async getCloudConfigWithRefresh(): Promise<CyncCloudConfig> {
393
548
  try {
394
549
  return await this.configClient.getCloudConfig();
@@ -408,18 +563,33 @@ export class CyncClient {
408
563
  }
409
564
  }
410
565
 
411
- // ### 🧩 Device Properties Wrapper: auto-refresh on Access-Token Expired for mesh calls
566
+ // Device Properties Wrapper: auto-refresh on Access-Token Expired for mesh calls
412
567
  private async getDevicePropertiesWithRefresh(
413
568
  productId: string | number,
414
569
  meshId: string | number,
415
570
  ): Promise<Record<string, unknown>> {
416
- // Normalise to strings for ConfigClient
417
571
  const productIdStr = String(productId);
418
572
  const meshIdStr = String(meshId);
419
573
 
574
+ // If we've already learned this product_id never supports the endpoint, skip.
575
+ if (this.unsupportedPropertiesProductIds.has(productIdStr)) {
576
+ return {};
577
+ }
578
+
420
579
  try {
421
580
  return await this.configClient.getDeviceProperties(productIdStr, meshIdStr);
422
581
  } catch (err) {
582
+ // Expected case for some product lines: endpoint not implemented.
583
+ if (this.isDevicePropertyNotExistsError(err)) {
584
+ this.unsupportedPropertiesProductIds.add(productIdStr);
585
+ this.log.debug(
586
+ 'CyncClient: properties unsupported for product_id=%s (mesh id=%s); caching and skipping.',
587
+ productIdStr,
588
+ meshIdStr,
589
+ );
590
+ return {};
591
+ }
592
+
423
593
  if (this.isAccessTokenExpiredError(err) && this.tokenData) {
424
594
  this.log.warn(
425
595
  'CyncClient: access token expired when calling getDeviceProperties(); refreshing and retrying once.',
@@ -504,7 +674,7 @@ export class CyncClient {
504
674
 
505
675
  // Debug: inspect per-mesh properties so we can find the real devices.
506
676
  for (const mesh of cfg.meshes) {
507
- const meshName = mesh.name ?? mesh.id;
677
+ const meshName = this.formatMeshLabel({ name: mesh.name, id: mesh.id });
508
678
  const homeId = String(mesh.id);
509
679
 
510
680
  this.log.debug(
@@ -517,7 +687,15 @@ export class CyncClient {
517
687
  // Per-home maps, mirroring HA's CyncUserData.get_cync_config()
518
688
  const homeDevices: string[] = [];
519
689
  const homeControllers: number[] = [];
520
-
690
+ const productIdStr = String(mesh.product_id);
691
+ if (this.unsupportedPropertiesProductIds.has(productIdStr)) {
692
+ this.log.debug(
693
+ 'CyncClient: skipping properties probe for mesh %s (product_id=%s) — previously marked unsupported.',
694
+ meshName,
695
+ productIdStr,
696
+ );
697
+ continue;
698
+ }
521
699
  try {
522
700
  const props = await this.getDevicePropertiesWithRefresh(
523
701
  mesh.product_id,
@@ -541,7 +719,7 @@ export class CyncClient {
541
719
  bulbsArray[0] ? Object.keys(bulbsArray[0] as Record<string, unknown>) : [],
542
720
  );
543
721
 
544
- // ### 🧩 Bulb Capability Debug: log each bulb so we can classify plugs vs lights
722
+ // Bulb Capability Debug: log each bulb so we can classify plugs vs lights
545
723
  bulbsArray.forEach((bulb, index) => {
546
724
  const record = bulb as Record<string, unknown>;
547
725
 
@@ -669,6 +847,15 @@ export class CyncClient {
669
847
  );
670
848
  }
671
849
  } catch (err) {
850
+ if (this.isDevicePropertyNotExistsError(err)) {
851
+ this.log.debug(
852
+ 'CyncClient: getDeviceProperties not supported for mesh %s (%s).',
853
+ meshName,
854
+ String(mesh.id),
855
+ );
856
+ continue;
857
+ }
858
+
672
859
  this.log.warn(
673
860
  'CyncClient: getDeviceProperties failed for mesh %s (%s): %s',
674
861
  meshName,