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.
- package/CHANGELOG.md +23 -11
- package/README.md +13 -19
- package/config.schema.json +2 -1
- package/dist/cync/cync-client.d.ts +8 -7
- package/dist/cync/cync-client.js +31 -17
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +10 -8
- package/dist/cync/tcp-client.js +63 -80
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/platform.d.ts +1 -4
- package/dist/platform.js +29 -18
- package/dist/platform.js.map +1 -1
- package/eslint.config.js +5 -1
- package/homebridge-ui/public/icon.png +0 -0
- package/homebridge-ui/public/index.html +182 -0
- package/homebridge-ui/server.js +62 -0
- package/package.json +2 -6
- package/src/cync/cync-client.ts +44 -22
- package/src/cync/tcp-client.ts +78 -111
- package/src/platform.ts +39 -20
package/src/cync/tcp-client.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
453
|
-
this.
|
|
454
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
445
|
+
const seq = this.nextSeq();
|
|
446
|
+
const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
|
|
478
447
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
181
|
-
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
|
|
214
|
-
|
|
215
|
-
const
|
|
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(
|