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/tcp-client.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// src/cync/tcp-client.ts
|
|
2
|
-
// Thin TCP client stub for talking to Cync WiFi devices.
|
|
3
|
-
// The binary protocol is non-trivial; for now this class only logs calls so
|
|
4
|
-
// that higher layers can be wired up and tested without crashing.
|
|
5
2
|
|
|
6
3
|
import { CyncCloudConfig, CyncLogger } from './config-client.js';
|
|
4
|
+
import net from 'net';
|
|
5
|
+
import tls from 'tls';
|
|
7
6
|
|
|
8
7
|
const defaultLogger: CyncLogger = {
|
|
9
8
|
debug: (...args: unknown[]) => console.debug('[cync-tcp]', ...args),
|
|
@@ -13,14 +12,109 @@ const defaultLogger: CyncLogger = {
|
|
|
13
12
|
};
|
|
14
13
|
|
|
15
14
|
export type DeviceUpdateCallback = (payload: unknown) => void;
|
|
15
|
+
export type RawFrameListener = (frame: Buffer) => void;
|
|
16
16
|
|
|
17
17
|
export class TcpClient {
|
|
18
|
+
public registerSwitchMapping(controllerId: number, deviceId: string): void {
|
|
19
|
+
if (!Number.isFinite(controllerId)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
this.controllerToDevice.set(controllerId, deviceId);
|
|
23
|
+
}
|
|
24
|
+
private homeDevices: Record<string, string[]> = {};
|
|
25
|
+
private switchIdToHomeId = new Map<number, string>();
|
|
18
26
|
private readonly log: CyncLogger;
|
|
19
|
-
|
|
27
|
+
private loginCode: Uint8Array | null = null;
|
|
28
|
+
private config: CyncCloudConfig | null = null;
|
|
29
|
+
private meshSockets = new Map<string, net.Socket>();
|
|
20
30
|
private deviceUpdateCb: DeviceUpdateCallback | null = null;
|
|
21
31
|
private roomUpdateCb: DeviceUpdateCallback | null = null;
|
|
22
32
|
private motionUpdateCb: DeviceUpdateCallback | null = null;
|
|
23
33
|
private ambientUpdateCb: DeviceUpdateCallback | null = null;
|
|
34
|
+
private socket: net.Socket | null = null;
|
|
35
|
+
private seq = 0;
|
|
36
|
+
private readBuffer = Buffer.alloc(0);
|
|
37
|
+
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
38
|
+
private rawFrameListeners: RawFrameListener[] = [];
|
|
39
|
+
private controllerToDevice = new Map<number, string>();
|
|
40
|
+
private parseSwitchStateFrame(frame: Buffer): { controllerId: number; on: boolean; level: number } | null {
|
|
41
|
+
if (frame.length < 16) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// First 4 bytes are the controller ID (big-endian)
|
|
46
|
+
const controllerId = frame.readUInt32BE(0);
|
|
47
|
+
|
|
48
|
+
// Look for the marker sequence db 11 02 01
|
|
49
|
+
const marker = Buffer.from('db110201', 'hex');
|
|
50
|
+
const idx = frame.indexOf(marker);
|
|
51
|
+
|
|
52
|
+
if (idx === -1) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// We need at least two bytes following the marker: onFlag + level
|
|
57
|
+
const onIndex = idx + marker.length;
|
|
58
|
+
const levelIndex = onIndex + 1;
|
|
59
|
+
|
|
60
|
+
if (levelIndex >= frame.length) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const onFlag = frame[onIndex];
|
|
65
|
+
const level = frame[levelIndex];
|
|
66
|
+
|
|
67
|
+
const on = onFlag === 0x01 && level > 0;
|
|
68
|
+
|
|
69
|
+
return { controllerId, on, level };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private parseLanSwitchUpdate(frame: Buffer): {
|
|
73
|
+
controllerId: number;
|
|
74
|
+
deviceId?: string;
|
|
75
|
+
on: boolean;
|
|
76
|
+
level: number;
|
|
77
|
+
} | null {
|
|
78
|
+
// Need at least enough bytes for the HA layout:
|
|
79
|
+
// switch_id(4) ... type(1) ... deviceIndex(1) ... state(1) ... brightness(1)
|
|
80
|
+
if (frame.length < 29) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const controllerId = frame.readUInt32BE(0);
|
|
85
|
+
|
|
86
|
+
const homeId = this.switchIdToHomeId.get(controllerId);
|
|
87
|
+
if (!homeId) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const devices = this.homeDevices[homeId];
|
|
92
|
+
if (!devices || devices.length === 0) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// HA checks: packet_length >= 33 and packet[13] == 219 (0xdb)
|
|
97
|
+
const typeByte = frame[13];
|
|
98
|
+
if (typeByte !== 0xdb) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const deviceIndex = frame[21];
|
|
103
|
+
const stateByte = frame[27];
|
|
104
|
+
const levelByte = frame[28];
|
|
105
|
+
|
|
106
|
+
const on = stateByte > 0;
|
|
107
|
+
const level = on ? levelByte : 0;
|
|
108
|
+
|
|
109
|
+
const deviceId = deviceIndex < devices.length ? devices[deviceIndex] : undefined;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
controllerId,
|
|
113
|
+
deviceId,
|
|
114
|
+
on,
|
|
115
|
+
level,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
24
118
|
|
|
25
119
|
constructor(logger?: CyncLogger) {
|
|
26
120
|
this.log = logger ?? defaultLogger;
|
|
@@ -29,28 +123,231 @@ export class TcpClient {
|
|
|
29
123
|
/**
|
|
30
124
|
* Establish a TCP session to one or more Cync devices.
|
|
31
125
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* For now this is a no-op that simply logs the request.
|
|
126
|
+
* For Homebridge:
|
|
127
|
+
* - We cache loginCode + config here.
|
|
128
|
+
* - Actual socket creation happens in ensureConnected()/establishSocket().
|
|
37
129
|
*/
|
|
38
130
|
public async connect(
|
|
39
131
|
loginCode: Uint8Array,
|
|
40
132
|
config: CyncCloudConfig,
|
|
41
133
|
): Promise<void> {
|
|
134
|
+
this.loginCode = loginCode;
|
|
135
|
+
this.config = config;
|
|
136
|
+
|
|
137
|
+
if (!loginCode.length) {
|
|
138
|
+
this.log.warn(
|
|
139
|
+
'[Cync TCP] connect() called with empty loginCode; LAN control will remain disabled.',
|
|
140
|
+
);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Optional eager connect at startup; failures are logged and we rely
|
|
145
|
+
// on ensureConnected() to reconnect on demand later.
|
|
146
|
+
await this.ensureConnected();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
public applyLanTopology(topology: {
|
|
151
|
+
homeDevices: Record<string, string[]>;
|
|
152
|
+
switchIdToHomeId: Record<number, string>;
|
|
153
|
+
}): void {
|
|
154
|
+
this.homeDevices = topology.homeDevices ?? {};
|
|
155
|
+
|
|
156
|
+
this.switchIdToHomeId = new Map<number, string>();
|
|
157
|
+
for (const [key, homeId] of Object.entries(topology.switchIdToHomeId ?? {})) {
|
|
158
|
+
const num = Number(key);
|
|
159
|
+
if (Number.isFinite(num)) {
|
|
160
|
+
this.switchIdToHomeId.set(num, homeId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
42
164
|
this.log.info(
|
|
43
|
-
'
|
|
44
|
-
|
|
45
|
-
|
|
165
|
+
'[Cync TCP] LAN topology applied: homes=%d controllers=%d',
|
|
166
|
+
Object.keys(this.homeDevices).length,
|
|
167
|
+
this.switchIdToHomeId.size,
|
|
46
168
|
);
|
|
47
169
|
}
|
|
48
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Ensure we have an open, logged-in socket.
|
|
173
|
+
* If the socket is closed or missing, attempt to reconnect.
|
|
174
|
+
*/
|
|
175
|
+
private async ensureConnected(): Promise<boolean> {
|
|
176
|
+
if (this.socket && !this.socket.destroyed) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!this.loginCode || !this.loginCode.length || !this.config) {
|
|
181
|
+
this.log.warn(
|
|
182
|
+
'[Cync TCP] ensureConnected() called without loginCode/config; cannot open socket.',
|
|
183
|
+
);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await this.establishSocket();
|
|
188
|
+
return !!(this.socket && !this.socket.destroyed);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Open a new socket to cm.gelighting.com and send the loginCode,
|
|
193
|
+
* mirroring the HA integration’s behavior.
|
|
194
|
+
*/
|
|
195
|
+
private async establishSocket(): Promise<void> {
|
|
196
|
+
const host = 'cm.gelighting.com';
|
|
197
|
+
const portTLS = 23779;
|
|
198
|
+
const portTCP = 23778;
|
|
199
|
+
|
|
200
|
+
this.log.info('[Cync TCP] Connecting to %s…', host);
|
|
201
|
+
|
|
202
|
+
let socket: net.Socket | null = null;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// 1. Try strict TLS
|
|
206
|
+
try {
|
|
207
|
+
socket = await this.openTlsSocket(host, portTLS, true);
|
|
208
|
+
} catch (e1) {
|
|
209
|
+
this.log.warn('[Cync TCP] TLS strict failed, trying relaxed TLS…');
|
|
210
|
+
try {
|
|
211
|
+
socket = await this.openTlsSocket(host, portTLS, false);
|
|
212
|
+
} catch (e2) {
|
|
213
|
+
this.log.warn(
|
|
214
|
+
'[Cync TCP] TLS relaxed failed, falling back to plain TCP…',
|
|
215
|
+
);
|
|
216
|
+
socket = await this.openTcpSocket(host, portTCP);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
this.log.error(
|
|
221
|
+
'[Cync TCP] Failed to connect to %s: %s',
|
|
222
|
+
host,
|
|
223
|
+
String(err),
|
|
224
|
+
);
|
|
225
|
+
this.socket = null;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!socket) {
|
|
230
|
+
this.log.error('[Cync TCP] Socket is null after connect attempts.');
|
|
231
|
+
this.socket = null;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.socket = socket;
|
|
236
|
+
this.attachSocketListeners(this.socket);
|
|
237
|
+
|
|
238
|
+
// Send loginCode immediately, as HA does.
|
|
239
|
+
if (this.loginCode && this.loginCode.length > 0) {
|
|
240
|
+
this.socket.write(Buffer.from(this.loginCode));
|
|
241
|
+
this.log.info(
|
|
242
|
+
'[Cync TCP] Login code sent (%d bytes).',
|
|
243
|
+
this.loginCode.length,
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
this.log.warn(
|
|
247
|
+
'[Cync TCP] establishSocket() reached with no loginCode; skipping auth write.',
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Start heartbeat: every 180 seconds send d3 00 00 00 00
|
|
252
|
+
this.startHeartbeat();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private openTlsSocket(host: string, port: number, strict: boolean): Promise<net.Socket> {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const sock = tls.connect(
|
|
258
|
+
{
|
|
259
|
+
host,
|
|
260
|
+
port,
|
|
261
|
+
rejectUnauthorized: strict,
|
|
262
|
+
},
|
|
263
|
+
() => resolve(sock),
|
|
264
|
+
);
|
|
265
|
+
sock.once('error', reject);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private openTcpSocket(host: string, port: number): Promise<net.Socket> {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const sock = net.createConnection({ host, port }, () => resolve(sock));
|
|
272
|
+
sock.once('error', reject);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private startHeartbeat(): void {
|
|
277
|
+
if (this.heartbeatTimer) {
|
|
278
|
+
clearInterval(this.heartbeatTimer);
|
|
279
|
+
}
|
|
280
|
+
this.heartbeatTimer = setInterval(() => {
|
|
281
|
+
if (!this.socket || this.socket.destroyed) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.socket.write(Buffer.from('d300000000', 'hex'));
|
|
285
|
+
}, 180_000);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private nextSeq(): number {
|
|
289
|
+
if (this.seq === 65535) {
|
|
290
|
+
this.seq = 1;
|
|
291
|
+
} else {
|
|
292
|
+
this.seq++;
|
|
293
|
+
}
|
|
294
|
+
return this.seq;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private buildPowerPacket(
|
|
298
|
+
controllerId: number,
|
|
299
|
+
meshId: number,
|
|
300
|
+
on: boolean,
|
|
301
|
+
seq: number,
|
|
302
|
+
): Buffer {
|
|
303
|
+
const header = Buffer.from('730000001f', 'hex');
|
|
304
|
+
|
|
305
|
+
const switchBytes = Buffer.alloc(4);
|
|
306
|
+
switchBytes.writeUInt32BE(controllerId, 0);
|
|
307
|
+
|
|
308
|
+
const seqBytes = Buffer.alloc(2);
|
|
309
|
+
seqBytes.writeUInt16BE(seq, 0);
|
|
310
|
+
|
|
311
|
+
const middle = Buffer.from('007e00000000f8d00d000000000000', 'hex');
|
|
312
|
+
|
|
313
|
+
const meshBytes = Buffer.alloc(2);
|
|
314
|
+
meshBytes.writeUInt16LE(meshId, 0);
|
|
315
|
+
|
|
316
|
+
const tail = Buffer.from(on ? 'd00000010000' : 'd00000000000', 'hex');
|
|
317
|
+
|
|
318
|
+
const checksumSeed = on ? 430 : 429;
|
|
319
|
+
const checksumByte =
|
|
320
|
+
(checksumSeed + meshBytes[0] + meshBytes[1]) & 0xff;
|
|
321
|
+
const checksum = Buffer.from([checksumByte]);
|
|
322
|
+
|
|
323
|
+
const end = Buffer.from('7e', 'hex');
|
|
324
|
+
|
|
325
|
+
return Buffer.concat([
|
|
326
|
+
header,
|
|
327
|
+
switchBytes,
|
|
328
|
+
seqBytes,
|
|
329
|
+
middle,
|
|
330
|
+
meshBytes,
|
|
331
|
+
tail,
|
|
332
|
+
checksum,
|
|
333
|
+
end,
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
|
|
49
337
|
public async disconnect(): Promise<void> {
|
|
50
|
-
this.log.info('
|
|
338
|
+
this.log.info('[Cync TCP] disconnect() called.');
|
|
339
|
+
if (this.heartbeatTimer) {
|
|
340
|
+
clearInterval(this.heartbeatTimer);
|
|
341
|
+
this.heartbeatTimer = null;
|
|
342
|
+
}
|
|
343
|
+
if (this.socket) {
|
|
344
|
+
this.socket.destroy();
|
|
345
|
+
this.socket = null;
|
|
346
|
+
}
|
|
51
347
|
}
|
|
52
348
|
|
|
53
349
|
public onDeviceUpdate(cb: DeviceUpdateCallback): void {
|
|
350
|
+
this.log.info('[Cync TCP] device update subscriber registered.');
|
|
54
351
|
this.deviceUpdateCb = cb;
|
|
55
352
|
}
|
|
56
353
|
|
|
@@ -66,23 +363,193 @@ export class TcpClient {
|
|
|
66
363
|
this.ambientUpdateCb = cb;
|
|
67
364
|
}
|
|
68
365
|
|
|
366
|
+
public onRawFrame(listener: RawFrameListener): void {
|
|
367
|
+
this.rawFrameListeners.push(listener);
|
|
368
|
+
}
|
|
369
|
+
|
|
69
370
|
/**
|
|
70
|
-
* High-level API to change switch state.
|
|
71
|
-
*
|
|
371
|
+
* High-level API to change switch state.
|
|
372
|
+
* Ensures we have a live socket before sending.
|
|
72
373
|
*/
|
|
73
374
|
public async setSwitchState(
|
|
74
375
|
deviceId: string,
|
|
75
|
-
params: { on: boolean
|
|
376
|
+
params: { on: boolean },
|
|
76
377
|
): Promise<void> {
|
|
378
|
+
if (!this.config) {
|
|
379
|
+
this.log.warn('[Cync TCP] No config available.');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const connected = await this.ensureConnected();
|
|
384
|
+
if (!connected || !this.socket || this.socket.destroyed) {
|
|
385
|
+
this.log.warn(
|
|
386
|
+
'[Cync TCP] Cannot send, socket not ready even after reconnect attempt.',
|
|
387
|
+
);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const device = this.findDevice(deviceId);
|
|
392
|
+
if (!device) {
|
|
393
|
+
this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const controllerId = Number((device as Record<string, unknown>).switch_controller);
|
|
398
|
+
const meshIndex = Number((device as Record<string, unknown>).mesh_id);
|
|
399
|
+
|
|
400
|
+
if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
|
|
401
|
+
this.log.warn(
|
|
402
|
+
'[Cync TCP] Device %s is missing LAN fields (switch_controller=%o mesh_id=%o)',
|
|
403
|
+
deviceId,
|
|
404
|
+
(device as Record<string, unknown>).switch_controller,
|
|
405
|
+
(device as Record<string, unknown>).mesh_id,
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const seq = this.nextSeq();
|
|
411
|
+
const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
|
|
412
|
+
|
|
413
|
+
this.socket.write(packet);
|
|
77
414
|
this.log.info(
|
|
78
|
-
'
|
|
415
|
+
'[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
|
|
79
416
|
deviceId,
|
|
417
|
+
String(params.on),
|
|
418
|
+
seq,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private findDevice(deviceId: string) {
|
|
423
|
+
for (const mesh of this.config?.meshes ?? []) {
|
|
424
|
+
for (const dev of mesh.devices ?? []) {
|
|
425
|
+
const record = dev as Record<string, unknown>;
|
|
426
|
+
const devDeviceId = record.device_id !== undefined && record.device_id !== null
|
|
427
|
+
? String(record.device_id)
|
|
428
|
+
: undefined;
|
|
429
|
+
const devId = record.id !== undefined && record.id !== null
|
|
430
|
+
? String(record.id)
|
|
431
|
+
: undefined;
|
|
432
|
+
|
|
433
|
+
if (devDeviceId === deviceId || devId === deviceId) {
|
|
434
|
+
return dev;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private attachSocketListeners(socket: net.Socket): void {
|
|
442
|
+
socket.on('data', (chunk) => {
|
|
443
|
+
this.log.debug('[Cync TCP] received %d bytes from server', chunk.byteLength);
|
|
444
|
+
this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
|
|
445
|
+
this.processIncoming();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
socket.on('close', () => {
|
|
449
|
+
this.log.warn('[Cync TCP] Socket closed.');
|
|
450
|
+
this.socket = null;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
socket.on('error', (err) => {
|
|
454
|
+
this.log.error('[Cync TCP] Socket error:', String(err));
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private processIncoming(): void {
|
|
459
|
+
while (this.readBuffer.length >= 5) {
|
|
460
|
+
const type = this.readBuffer.readUInt8(0);
|
|
461
|
+
const len = this.readBuffer.readUInt32BE(1);
|
|
462
|
+
const total = 5 + len;
|
|
463
|
+
|
|
464
|
+
if (this.readBuffer.length < total) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const body = this.readBuffer.subarray(5, total);
|
|
469
|
+
|
|
470
|
+
// Debug log with full hex dump so we can reverse-engineer the protocol
|
|
471
|
+
this.log.debug(
|
|
472
|
+
'[Cync TCP] frame type=0x%s len=%d body=%s',
|
|
473
|
+
type.toString(16).padStart(2, '0'),
|
|
474
|
+
len,
|
|
475
|
+
body.toString('hex'),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (type === 0x7b && body.length >= 6) {
|
|
479
|
+
const seq = body.readUInt16BE(4);
|
|
480
|
+
this.log.debug('[Cync TCP] ACK for seq=%d', seq);
|
|
481
|
+
} else {
|
|
482
|
+
this.handleIncomingFrame(body, type);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.readBuffer = this.readBuffer.subarray(total);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private async sendRawCommand(
|
|
490
|
+
deviceId: string,
|
|
491
|
+
command: string,
|
|
492
|
+
params: Record<string, unknown>,
|
|
493
|
+
): Promise<void> {
|
|
494
|
+
if (!this.config || !this.loginCode) {
|
|
495
|
+
this.log.warn(
|
|
496
|
+
'TcpClient.sendRawCommand() called before connect(); deviceId=%s command=%s params=%o',
|
|
497
|
+
deviceId,
|
|
498
|
+
command,
|
|
499
|
+
params,
|
|
500
|
+
);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
this.log.info(
|
|
505
|
+
'TcpClient.sendRawCommand() stub: deviceId=%s command=%s params=%o',
|
|
506
|
+
deviceId,
|
|
507
|
+
command,
|
|
80
508
|
params,
|
|
81
509
|
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ### 🧩 Incoming Frame Handler: routes LAN messages to raw + parsed callbacks
|
|
513
|
+
private handleIncomingFrame(frame: Buffer, type: number): void {
|
|
514
|
+
// Fan out raw frame to higher layers (CyncClient) for debugging
|
|
515
|
+
for (const listener of this.rawFrameListeners) {
|
|
516
|
+
try {
|
|
517
|
+
listener(frame);
|
|
518
|
+
} catch (err) {
|
|
519
|
+
this.log.error(
|
|
520
|
+
'[Cync TCP] raw frame listener threw: %s',
|
|
521
|
+
String(err),
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Default payload is the raw frame
|
|
527
|
+
let payload: unknown = frame;
|
|
528
|
+
|
|
529
|
+
if (type === 0x83) {
|
|
530
|
+
// Preferred path: HA-style per-device parsing using homeDevices + switchIdToHomeId
|
|
531
|
+
const lanParsed = this.parseLanSwitchUpdate(frame);
|
|
532
|
+
if (lanParsed) {
|
|
533
|
+
payload = lanParsed;
|
|
534
|
+
} else {
|
|
535
|
+
// Fallback to legacy controller-level parsing
|
|
536
|
+
const parsed = this.parseSwitchStateFrame(frame);
|
|
537
|
+
if (parsed) {
|
|
538
|
+
const deviceId = this.controllerToDevice.get(parsed.controllerId);
|
|
539
|
+
payload = {
|
|
540
|
+
...parsed,
|
|
541
|
+
deviceId,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
82
546
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
547
|
+
if (this.deviceUpdateCb) {
|
|
548
|
+
this.deviceUpdateCb(payload);
|
|
549
|
+
} else {
|
|
550
|
+
this.log.debug(
|
|
551
|
+
'[Cync TCP] Dropping device update frame (no subscriber).',
|
|
552
|
+
);
|
|
553
|
+
}
|
|
87
554
|
}
|
|
88
555
|
}
|
package/src/cync/token-store.ts
CHANGED