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.
- package/CHANGELOG.md +13 -0
- package/dist/cync/config-client.d.ts +11 -0
- package/dist/cync/config-client.js +113 -6
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-accessory-helpers.d.ts +46 -0
- package/dist/cync/cync-accessory-helpers.js +140 -0
- package/dist/cync/cync-accessory-helpers.js.map +1 -0
- package/dist/cync/cync-client.d.ts +4 -0
- package/dist/cync/cync-client.js +150 -34
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/cync-light-accessory.d.ts +4 -0
- package/dist/cync/cync-light-accessory.js +190 -0
- package/dist/cync/cync-light-accessory.js.map +1 -0
- package/dist/cync/cync-switch-accessory.d.ts +4 -0
- package/dist/cync/cync-switch-accessory.js +64 -0
- package/dist/cync/cync-switch-accessory.js.map +1 -0
- package/dist/cync/device-catalog.js +9 -4
- package/dist/cync/device-catalog.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +7 -0
- package/dist/cync/tcp-client.js +122 -30
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.js +2 -2
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +1 -3
- package/dist/platform.js +18 -382
- package/dist/platform.js.map +1 -1
- package/package.json +1 -1
- package/src/cync/config-client.ts +175 -12
- package/src/cync/cync-accessory-helpers.ts +233 -0
- package/src/cync/cync-client.ts +231 -44
- package/src/cync/cync-light-accessory.ts +369 -0
- package/src/cync/cync-switch-accessory.ts +119 -0
- package/src/cync/device-catalog.ts +9 -4
- package/src/cync/tcp-client.ts +153 -53
- package/src/cync/token-store.ts +3 -2
- package/src/platform.ts +49 -661
package/src/cync/cync-client.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
444
|
+
// Refresh Error Detector: identifies "Access-Token Expired" responses
|
|
325
445
|
private isAccessTokenExpiredError(err: unknown): boolean {
|
|
326
|
-
if (!err
|
|
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
|
|
471
|
+
// Shape we see in raw HTTP JSON:
|
|
342
472
|
// { error: { msg: 'Access-Token Expired', code: 4031021 } }
|
|
343
|
-
if (
|
|
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
|
|
348
|
-
if (
|
|
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
|
-
//
|
|
356
|
-
private async refreshAccessToken(
|
|
357
|
-
|
|
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;
|
|
529
|
+
'CyncClient: refreshAccessToken() called but no refreshToken is stored; will attempt password-based login.',
|
|
360
530
|
);
|
|
361
|
-
return null;
|
|
362
531
|
}
|
|
363
532
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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,
|