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.
- package/CHANGELOG.md +9 -0
- package/README.md +7 -4
- package/dist/cync/config-client.d.ts +6 -0
- package/dist/cync/config-client.js +38 -0
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-client.d.ts +16 -11
- package/dist/cync/cync-client.js +175 -23
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +45 -8
- package/dist/cync/tcp-client.js +359 -17
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.d.ts +2 -0
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +5 -2
- package/dist/platform.js +103 -36
- package/dist/platform.js.map +1 -1
- package/docs/cync-api-notes.md +121 -7
- package/package.json +1 -1
- package/src/cync/config-client.ts +55 -0
- package/src/cync/cync-client.ts +221 -35
- package/src/cync/tcp-client.ts +488 -21
- package/src/cync/token-store.ts +3 -1
- package/src/platform.ts +176 -50
- package/homebridge-cync-app-v0.0.1.zip +0 -0
package/src/cync/cync-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
450
|
+
deviceIdStr ??
|
|
319
451
|
(wifiMac ? wifiMac.replace(/:/g, '') : undefined) ??
|
|
320
452
|
`${mesh.id}-${productId ?? 'unknown'}`;
|
|
321
453
|
|
|
322
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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.
|
|
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(
|