homebridge-cync-app 0.1.5 → 0.1.7
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/.github/ISSUE_TEMPLATE/add-support-for-a-new-cync-device.md +139 -0
- package/CHANGELOG.md +30 -0
- package/README.md +20 -3
- package/dist/cync/config-client.d.ts +12 -4
- package/dist/cync/config-client.js +55 -1
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-client.d.ts +4 -0
- package/dist/cync/cync-client.js +92 -2
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/device-catalog.d.ts +19 -0
- package/dist/cync/device-catalog.js +35 -0
- package/dist/cync/device-catalog.js.map +1 -0
- package/dist/cync/tcp-client.d.ts +7 -18
- package/dist/cync/tcp-client.js +117 -22
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.d.ts +4 -0
- package/dist/cync/token-store.js +10 -2
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +9 -12
- package/dist/platform.js +415 -27
- package/dist/platform.js.map +1 -1
- package/dist/platformAccessory.js +1 -0
- package/dist/platformAccessory.js.map +1 -1
- package/homebridge-ui/public/icon.png +0 -0
- package/package.json +6 -3
- package/src/cync/config-client.ts +80 -6
- package/src/cync/cync-client.ts +136 -2
- package/src/cync/device-catalog.ts +55 -0
- package/src/cync/tcp-client.ts +214 -23
- package/src/cync/token-store.ts +11 -2
- package/src/platform.ts +687 -34
- package/src/platformAccessory.ts +2 -0
- package/nodemon.json +0 -12
- package/src/@types/homebridge-lib.d.ts +0 -14
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
const CYNC_API_BASE = 'https://api.gelighting.com/v2/';
|
|
9
9
|
const CORP_ID = '1007d2ad150c4000';
|
|
10
10
|
|
|
11
|
-
// Minimal fetch/response typing for Node 18+, without depending on DOM lib types.
|
|
12
11
|
type FetchLike = (input: unknown, init?: unknown) => Promise<unknown>;
|
|
13
12
|
|
|
14
13
|
declare const fetch: FetchLike;
|
|
@@ -65,10 +64,12 @@ export interface CyncCloudConfig {
|
|
|
65
64
|
meshes: CyncDeviceMesh[];
|
|
66
65
|
}
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
export interface CyncRefreshResponse {
|
|
68
|
+
accessToken: string;
|
|
69
|
+
refreshToken?: string;
|
|
70
|
+
expiresAt?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
72
73
|
export interface CyncLogger {
|
|
73
74
|
debug(message: string, ...args: unknown[]): void;
|
|
74
75
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -151,7 +152,6 @@ export class ConfigClient {
|
|
|
151
152
|
email,
|
|
152
153
|
password,
|
|
153
154
|
two_factor: otpCode,
|
|
154
|
-
// Matches the reference implementations: random 16-char string.
|
|
155
155
|
resource: ConfigClient.randomLoginResource(),
|
|
156
156
|
};
|
|
157
157
|
|
|
@@ -223,6 +223,80 @@ export class ConfigClient {
|
|
|
223
223
|
raw: json,
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Refresh the access token using a stored refresh token.
|
|
228
|
+
*
|
|
229
|
+
* This mirrors the 2FA login flow in shape, but only exchanges the
|
|
230
|
+
* refresh_token for a new access_token (and possibly a new refresh_token).
|
|
231
|
+
*/
|
|
232
|
+
public async refreshAccessToken(refreshToken: string): Promise<CyncRefreshResponse> {
|
|
233
|
+
const url = `${CYNC_API_BASE}user_auth/refresh`;
|
|
234
|
+
this.log.debug('Refreshing Cync access token…');
|
|
235
|
+
|
|
236
|
+
const body = {
|
|
237
|
+
corp_id: CORP_ID,
|
|
238
|
+
refresh_token: refreshToken,
|
|
239
|
+
resource: ConfigClient.randomLoginResource(),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const res = (await fetch(url, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
'Content-Type': 'application/json',
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify(body),
|
|
248
|
+
})) as HttpResponse;
|
|
249
|
+
|
|
250
|
+
const json: unknown = await res.json().catch(async () => {
|
|
251
|
+
const text = await res.text().catch(() => '');
|
|
252
|
+
throw new Error(`Cync refresh returned non-JSON payload: ${text}`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
this.log.error(
|
|
257
|
+
'Cync refresh failed: HTTP %d %s %o',
|
|
258
|
+
res.status,
|
|
259
|
+
res.statusText,
|
|
260
|
+
json,
|
|
261
|
+
);
|
|
262
|
+
const errBody = json as CyncErrorBody;
|
|
263
|
+
const msg =
|
|
264
|
+
errBody.error?.msg ??
|
|
265
|
+
`Cync refresh failed with status ${res.status} ${res.statusText}`;
|
|
266
|
+
throw new Error(msg);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const obj = json as Record<string, unknown>;
|
|
270
|
+
this.log.debug('Cync refresh response: keys=%o', Object.keys(obj));
|
|
271
|
+
|
|
272
|
+
const accessTokenRaw = obj.access_token ?? obj.accessToken;
|
|
273
|
+
const refreshTokenRaw = obj.refresh_token ?? obj.refreshToken;
|
|
274
|
+
const expiresAtRaw = obj.expires_at ?? obj.expiresAt;
|
|
275
|
+
|
|
276
|
+
const accessToken =
|
|
277
|
+
typeof accessTokenRaw === 'string' && accessTokenRaw.length > 0
|
|
278
|
+
? accessTokenRaw
|
|
279
|
+
: undefined;
|
|
280
|
+
|
|
281
|
+
if (!accessToken) {
|
|
282
|
+
this.log.error('Cync refresh missing access_token: %o', json);
|
|
283
|
+
throw new Error('Cync refresh response missing access_token');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const next: CyncRefreshResponse = {
|
|
287
|
+
accessToken,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
if (typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0) {
|
|
291
|
+
next.refreshToken = refreshTokenRaw;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (typeof expiresAtRaw === 'number') {
|
|
295
|
+
next.expiresAt = expiresAtRaw;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return next;
|
|
299
|
+
}
|
|
226
300
|
|
|
227
301
|
/**
|
|
228
302
|
* Fetch the list of meshes/devices for the current user from the cloud.
|
package/src/cync/cync-client.ts
CHANGED
|
@@ -321,6 +321,120 @@ export class CyncClient {
|
|
|
321
321
|
* // user reads email, gets code…
|
|
322
322
|
* await client.submitTwoFactor(username, password, code); // completes login
|
|
323
323
|
*/
|
|
324
|
+
// ### 🧩 Refresh Error Detector: identifies "Access-Token Expired" responses
|
|
325
|
+
private isAccessTokenExpiredError(err: unknown): boolean {
|
|
326
|
+
if (!err || typeof err !== 'object') {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
type ErrorWithShape = {
|
|
331
|
+
status?: number;
|
|
332
|
+
message?: string;
|
|
333
|
+
error?: {
|
|
334
|
+
msg?: string;
|
|
335
|
+
code?: number;
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const e = err as ErrorWithShape;
|
|
340
|
+
|
|
341
|
+
// Shape we see in logs:
|
|
342
|
+
// { error: { msg: 'Access-Token Expired', code: 4031021 } }
|
|
343
|
+
if (e.error && (e.error.msg === 'Access-Token Expired' || e.error.code === 4031021)) {
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Fallback: generic 403 with message string
|
|
348
|
+
if (e.status === 403 && e.message && e.message.includes('Access-Token Expired')) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ### 🧩 Token Refresh Helper: exchanges refreshToken for a new accessToken
|
|
356
|
+
private async refreshAccessToken(stored: CyncTokenData): Promise<CyncTokenData | null> {
|
|
357
|
+
if (!stored.refreshToken) {
|
|
358
|
+
this.log.warn(
|
|
359
|
+
'CyncClient: refreshAccessToken() called but no refreshToken is stored; cannot refresh.',
|
|
360
|
+
);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
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',
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
return next;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
this.log.error('CyncClient: token refresh failed: %o', err);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ### 🧩 Cloud Config Wrapper: auto-refreshes access token
|
|
392
|
+
private async getCloudConfigWithRefresh(): Promise<CyncCloudConfig> {
|
|
393
|
+
try {
|
|
394
|
+
return await this.configClient.getCloudConfig();
|
|
395
|
+
} catch (err) {
|
|
396
|
+
if (this.isAccessTokenExpiredError(err) && this.tokenData) {
|
|
397
|
+
this.log.warn(
|
|
398
|
+
'CyncClient: access token expired when calling getCloudConfig(); refreshing and retrying once.',
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const refreshed = await this.refreshAccessToken(this.tokenData);
|
|
402
|
+
if (refreshed) {
|
|
403
|
+
return await this.configClient.getCloudConfig();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ### 🧩 Device Properties Wrapper: auto-refresh on Access-Token Expired for mesh calls
|
|
412
|
+
private async getDevicePropertiesWithRefresh(
|
|
413
|
+
productId: string | number,
|
|
414
|
+
meshId: string | number,
|
|
415
|
+
): Promise<Record<string, unknown>> {
|
|
416
|
+
// Normalise to strings for ConfigClient
|
|
417
|
+
const productIdStr = String(productId);
|
|
418
|
+
const meshIdStr = String(meshId);
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
return await this.configClient.getDeviceProperties(productIdStr, meshIdStr);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (this.isAccessTokenExpiredError(err) && this.tokenData) {
|
|
424
|
+
this.log.warn(
|
|
425
|
+
'CyncClient: access token expired when calling getDeviceProperties(); refreshing and retrying once.',
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const refreshed = await this.refreshAccessToken(this.tokenData);
|
|
429
|
+
if (refreshed) {
|
|
430
|
+
return await this.configClient.getDeviceProperties(productIdStr, meshIdStr);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
324
438
|
public async authenticate(username: string): Promise<void> {
|
|
325
439
|
const email = username.trim();
|
|
326
440
|
|
|
@@ -381,7 +495,7 @@ export class CyncClient {
|
|
|
381
495
|
this.ensureSession();
|
|
382
496
|
|
|
383
497
|
this.log.info('CyncClient: loading Cync cloud configuration…');
|
|
384
|
-
const cfg = await this.
|
|
498
|
+
const cfg = await this.getCloudConfigWithRefresh();
|
|
385
499
|
|
|
386
500
|
// Reset LAN topology caches on each reload
|
|
387
501
|
this.homeDevices = {};
|
|
@@ -405,7 +519,7 @@ export class CyncClient {
|
|
|
405
519
|
const homeControllers: number[] = [];
|
|
406
520
|
|
|
407
521
|
try {
|
|
408
|
-
const props = await this.
|
|
522
|
+
const props = await this.getDevicePropertiesWithRefresh(
|
|
409
523
|
mesh.product_id,
|
|
410
524
|
mesh.id,
|
|
411
525
|
);
|
|
@@ -427,6 +541,26 @@ export class CyncClient {
|
|
|
427
541
|
bulbsArray[0] ? Object.keys(bulbsArray[0] as Record<string, unknown>) : [],
|
|
428
542
|
);
|
|
429
543
|
|
|
544
|
+
// ### 🧩 Bulb Capability Debug: log each bulb so we can classify plugs vs lights
|
|
545
|
+
bulbsArray.forEach((bulb, index) => {
|
|
546
|
+
const record = bulb as Record<string, unknown>;
|
|
547
|
+
|
|
548
|
+
this.log.debug(
|
|
549
|
+
'CyncClient: bulb #%d for mesh %s → %o',
|
|
550
|
+
index,
|
|
551
|
+
meshName,
|
|
552
|
+
{
|
|
553
|
+
displayName: record.displayName,
|
|
554
|
+
deviceID: record.deviceID ?? record.deviceId,
|
|
555
|
+
deviceType: record.deviceType,
|
|
556
|
+
loadSelection: record.loadSelection,
|
|
557
|
+
defaultBrightness: record.defaultBrightness,
|
|
558
|
+
lightRingColor: record.lightRingColor,
|
|
559
|
+
raw: record,
|
|
560
|
+
},
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
430
564
|
type RawDevice = Record<string, unknown>;
|
|
431
565
|
const rawDevices = bulbsArray as unknown[];
|
|
432
566
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// src/cync/device-catalog.ts
|
|
2
|
+
|
|
3
|
+
import type { Categories } from 'homebridge';
|
|
4
|
+
|
|
5
|
+
export interface CyncDeviceModel {
|
|
6
|
+
/** Raw deviceType from the Cync API */
|
|
7
|
+
deviceType: number;
|
|
8
|
+
|
|
9
|
+
/** Model name as shown in the Cync app (what you want HomeKit to show) */
|
|
10
|
+
modelName: string;
|
|
11
|
+
|
|
12
|
+
/** Optional marketing / retail name if you want to surface it somewhere else */
|
|
13
|
+
marketingName?: string;
|
|
14
|
+
|
|
15
|
+
/** Optional suggested HomeKit category override */
|
|
16
|
+
defaultCategory?: Categories;
|
|
17
|
+
|
|
18
|
+
/** Free-form notes for you / debugging */
|
|
19
|
+
notes?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Device catalog keyed by deviceType.
|
|
24
|
+
* Extend this as you discover more types.
|
|
25
|
+
*/
|
|
26
|
+
export const DEVICE_CATALOG: Record<number, CyncDeviceModel> = {
|
|
27
|
+
46: {
|
|
28
|
+
deviceType: 46,
|
|
29
|
+
modelName: '6" Recessed Can Retrofit Fixture (Matter)',
|
|
30
|
+
marketingName: 'Cync reveal HD+',
|
|
31
|
+
// defaultCategory: Categories.LIGHTBULB,
|
|
32
|
+
},
|
|
33
|
+
64: {
|
|
34
|
+
deviceType: 64,
|
|
35
|
+
modelName: 'Indoor Smart Plug',
|
|
36
|
+
marketingName: 'On/Off Smart Plug',
|
|
37
|
+
// defaultCategory: Categories.OUTLET,
|
|
38
|
+
},
|
|
39
|
+
65: {
|
|
40
|
+
deviceType: 65,
|
|
41
|
+
modelName: 'Indoor Smart Plug',
|
|
42
|
+
marketingName: 'Cync Indoor Plug',
|
|
43
|
+
// defaultCategory: Categories.OUTLET,
|
|
44
|
+
},
|
|
45
|
+
172: {
|
|
46
|
+
deviceType: 172,
|
|
47
|
+
modelName: 'Indoor Smart Plug (3in1)',
|
|
48
|
+
marketingName: 'Cync Indoor Smart Plug',
|
|
49
|
+
// defaultCategory: Categories.OUTLET,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function lookupDeviceModel(deviceType: number): CyncDeviceModel | undefined {
|
|
54
|
+
return DEVICE_CATALOG[deviceType];
|
|
55
|
+
}
|
package/src/cync/tcp-client.ts
CHANGED
|
@@ -38,12 +38,7 @@ export class TcpClient {
|
|
|
38
38
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
39
39
|
private rawFrameListeners: RawFrameListener[] = [];
|
|
40
40
|
private controllerToDevice = new Map<number, string>();
|
|
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
|
-
*/
|
|
41
|
+
|
|
47
42
|
private enqueueCommand<T>(fn: () => Promise<T>): Promise<T> {
|
|
48
43
|
let resolveWrapper: (value: T | PromiseLike<T>) => void;
|
|
49
44
|
let rejectWrapper: (reason?: unknown) => void;
|
|
@@ -179,7 +174,6 @@ export class TcpClient {
|
|
|
179
174
|
await this.ensureConnected();
|
|
180
175
|
}
|
|
181
176
|
|
|
182
|
-
|
|
183
177
|
public applyLanTopology(topology: {
|
|
184
178
|
homeDevices: Record<string, string[]>;
|
|
185
179
|
switchIdToHomeId: Record<number, string>;
|
|
@@ -201,10 +195,6 @@ export class TcpClient {
|
|
|
201
195
|
);
|
|
202
196
|
}
|
|
203
197
|
|
|
204
|
-
/**
|
|
205
|
-
* Ensure we have an open, logged-in socket.
|
|
206
|
-
* If the socket is closed or missing, attempt to reconnect.
|
|
207
|
-
*/
|
|
208
198
|
private async ensureConnected(): Promise<boolean> {
|
|
209
199
|
if (this.socket && !this.socket.destroyed) {
|
|
210
200
|
return true;
|
|
@@ -221,10 +211,6 @@ export class TcpClient {
|
|
|
221
211
|
return !!(this.socket && !this.socket.destroyed);
|
|
222
212
|
}
|
|
223
213
|
|
|
224
|
-
/**
|
|
225
|
-
* Open a new socket to cm.gelighting.com and send the loginCode,
|
|
226
|
-
* mirroring the HA integration’s behavior.
|
|
227
|
-
*/
|
|
228
214
|
private async establishSocket(): Promise<void> {
|
|
229
215
|
const host = 'cm.gelighting.com';
|
|
230
216
|
const portTLS = 23779;
|
|
@@ -367,6 +353,69 @@ export class TcpClient {
|
|
|
367
353
|
]);
|
|
368
354
|
}
|
|
369
355
|
|
|
356
|
+
private buildComboPacket(
|
|
357
|
+
controllerId: number,
|
|
358
|
+
meshId: number,
|
|
359
|
+
on: boolean,
|
|
360
|
+
brightnessPct: number,
|
|
361
|
+
colorTone: number,
|
|
362
|
+
rgb: { r: number; g: number; b: number },
|
|
363
|
+
seq: number,
|
|
364
|
+
): Buffer {
|
|
365
|
+
const header = Buffer.from('7300000022', 'hex');
|
|
366
|
+
|
|
367
|
+
const switchBytes = Buffer.alloc(4);
|
|
368
|
+
switchBytes.writeUInt32BE(controllerId, 0);
|
|
369
|
+
|
|
370
|
+
const seqBytes = Buffer.alloc(2);
|
|
371
|
+
seqBytes.writeUInt16BE(seq, 0);
|
|
372
|
+
|
|
373
|
+
const middle = Buffer.from('007e00000000f8f010000000000000', 'hex');
|
|
374
|
+
|
|
375
|
+
const meshBytes = Buffer.alloc(2);
|
|
376
|
+
meshBytes.writeUInt16LE(meshId, 0);
|
|
377
|
+
|
|
378
|
+
const tailPrefix = Buffer.from('f00000', 'hex');
|
|
379
|
+
|
|
380
|
+
const onByte = on ? 1 : 0;
|
|
381
|
+
const brightnessByte = Math.max(0, Math.min(100, Math.round(brightnessPct)));
|
|
382
|
+
const colorToneByte = Math.max(0, Math.min(255, Math.round(colorTone)));
|
|
383
|
+
|
|
384
|
+
const r = Math.max(0, Math.min(255, Math.round(rgb.r)));
|
|
385
|
+
const g = Math.max(0, Math.min(255, Math.round(rgb.g)));
|
|
386
|
+
const b = Math.max(0, Math.min(255, Math.round(rgb.b)));
|
|
387
|
+
|
|
388
|
+
const rgbBytes = Buffer.from([r, g, b]);
|
|
389
|
+
|
|
390
|
+
const checksumSeed =
|
|
391
|
+
496 +
|
|
392
|
+
meshBytes[0] +
|
|
393
|
+
meshBytes[1] +
|
|
394
|
+
onByte +
|
|
395
|
+
brightnessByte +
|
|
396
|
+
colorToneByte +
|
|
397
|
+
r +
|
|
398
|
+
g +
|
|
399
|
+
b;
|
|
400
|
+
|
|
401
|
+
const checksum = Buffer.from([checksumSeed & 0xff]);
|
|
402
|
+
const end = Buffer.from('7e', 'hex');
|
|
403
|
+
|
|
404
|
+
return Buffer.concat([
|
|
405
|
+
header,
|
|
406
|
+
switchBytes,
|
|
407
|
+
seqBytes,
|
|
408
|
+
middle,
|
|
409
|
+
meshBytes,
|
|
410
|
+
tailPrefix,
|
|
411
|
+
Buffer.from([onByte]),
|
|
412
|
+
Buffer.from([brightnessByte]),
|
|
413
|
+
Buffer.from([colorToneByte]),
|
|
414
|
+
rgbBytes,
|
|
415
|
+
checksum,
|
|
416
|
+
end,
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
370
419
|
public async disconnect(): Promise<void> {
|
|
371
420
|
this.log.info('[Cync TCP] disconnect() called.');
|
|
372
421
|
if (this.heartbeatTimer) {
|
|
@@ -400,10 +449,6 @@ export class TcpClient {
|
|
|
400
449
|
this.rawFrameListeners.push(listener);
|
|
401
450
|
}
|
|
402
451
|
|
|
403
|
-
/**
|
|
404
|
-
* High-level API to change switch state.
|
|
405
|
-
* Ensures we have a live socket before sending and serializes commands.
|
|
406
|
-
*/
|
|
407
452
|
public async setSwitchState(
|
|
408
453
|
deviceId: string,
|
|
409
454
|
params: { on: boolean },
|
|
@@ -429,6 +474,14 @@ export class TcpClient {
|
|
|
429
474
|
}
|
|
430
475
|
|
|
431
476
|
const record = device as Record<string, unknown>;
|
|
477
|
+
this.log.debug(
|
|
478
|
+
'[Cync TCP] setSwitchState: deviceId=%s device_type=%o switch_controller=%o mesh_id=%o home_id=%o',
|
|
479
|
+
deviceId,
|
|
480
|
+
record.device_type,
|
|
481
|
+
record.switch_controller,
|
|
482
|
+
record.mesh_id,
|
|
483
|
+
record.home_id,
|
|
484
|
+
);
|
|
432
485
|
const controllerId = Number(record.switch_controller);
|
|
433
486
|
const meshIndex = Number(record.mesh_id);
|
|
434
487
|
|
|
@@ -456,6 +509,143 @@ export class TcpClient {
|
|
|
456
509
|
});
|
|
457
510
|
}
|
|
458
511
|
|
|
512
|
+
public async setBrightness(deviceId: string, brightnessPct: number): Promise<void> {
|
|
513
|
+
return this.enqueueCommand(async () => {
|
|
514
|
+
if (!this.config) {
|
|
515
|
+
this.log.warn('[Cync TCP] setBrightness: no config available.');
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const connected = await this.ensureConnected();
|
|
520
|
+
if (!connected || !this.socket || this.socket.destroyed) {
|
|
521
|
+
this.log.warn(
|
|
522
|
+
'[Cync TCP] setBrightness: socket not ready even after reconnect attempt.',
|
|
523
|
+
);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const device = this.findDevice(deviceId);
|
|
528
|
+
if (!device) {
|
|
529
|
+
this.log.warn('[Cync TCP] setBrightness: unknown deviceId=%s', deviceId);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const record = device as Record<string, unknown>;
|
|
534
|
+
const controllerId = Number(record.switch_controller);
|
|
535
|
+
const meshIndex = Number(record.mesh_id);
|
|
536
|
+
|
|
537
|
+
if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
|
|
538
|
+
this.log.warn(
|
|
539
|
+
'[Cync TCP] setBrightness: device %s missing LAN fields (switch_controller=%o mesh_id=%o)',
|
|
540
|
+
deviceId,
|
|
541
|
+
record.switch_controller,
|
|
542
|
+
record.mesh_id,
|
|
543
|
+
);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const clamped = Math.max(0, Math.min(100, Number(brightnessPct)));
|
|
548
|
+
const on = clamped > 0;
|
|
549
|
+
|
|
550
|
+
const seq = this.nextSeq();
|
|
551
|
+
|
|
552
|
+
// White-only combo control for now; color support can layer on this later.
|
|
553
|
+
const packet = this.buildComboPacket(
|
|
554
|
+
controllerId,
|
|
555
|
+
meshIndex,
|
|
556
|
+
on,
|
|
557
|
+
clamped,
|
|
558
|
+
255, // color_tone=255 ("white" in HA integration)
|
|
559
|
+
{ r: 255, g: 255, b: 255 },
|
|
560
|
+
seq,
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
this.socket.write(packet);
|
|
564
|
+
this.log.info(
|
|
565
|
+
'[Cync TCP] Sent combo (brightness) packet: device=%s on=%s brightness=%d seq=%d',
|
|
566
|
+
deviceId,
|
|
567
|
+
String(on),
|
|
568
|
+
clamped,
|
|
569
|
+
seq,
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
public async setColor(
|
|
575
|
+
deviceId: string,
|
|
576
|
+
rgb: { r: number; g: number; b: number },
|
|
577
|
+
brightnessPct?: number,
|
|
578
|
+
): Promise<void> {
|
|
579
|
+
return this.enqueueCommand(async () => {
|
|
580
|
+
if (!this.config) {
|
|
581
|
+
this.log.warn('[Cync TCP] setColor: no config available.');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const connected = await this.ensureConnected();
|
|
586
|
+
if (!connected || !this.socket || this.socket.destroyed) {
|
|
587
|
+
this.log.warn(
|
|
588
|
+
'[Cync TCP] setColor: socket not ready even after reconnect attempt.',
|
|
589
|
+
);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const device = this.findDevice(deviceId);
|
|
594
|
+
if (!device) {
|
|
595
|
+
this.log.warn('[Cync TCP] setColor: unknown deviceId=%s', deviceId);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const record = device as Record<string, unknown>;
|
|
600
|
+
const controllerId = Number(record.switch_controller);
|
|
601
|
+
const meshIndex = Number(record.mesh_id);
|
|
602
|
+
|
|
603
|
+
if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
|
|
604
|
+
this.log.warn(
|
|
605
|
+
'[Cync TCP] setColor: device %s missing LAN fields (switch_controller=%o mesh_id=%o)',
|
|
606
|
+
deviceId,
|
|
607
|
+
record.switch_controller,
|
|
608
|
+
record.mesh_id,
|
|
609
|
+
);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const clampedBrightness = Math.max(
|
|
614
|
+
0,
|
|
615
|
+
Math.min(100, Math.round(brightnessPct ?? 100)),
|
|
616
|
+
);
|
|
617
|
+
const on = clampedBrightness > 0;
|
|
618
|
+
|
|
619
|
+
const r = Math.max(0, Math.min(255, Math.round(rgb.r)));
|
|
620
|
+
const g = Math.max(0, Math.min(255, Math.round(rgb.g)));
|
|
621
|
+
const b = Math.max(0, Math.min(255, Math.round(rgb.b)));
|
|
622
|
+
|
|
623
|
+
const seq = this.nextSeq();
|
|
624
|
+
|
|
625
|
+
// colorTone=254 => "color" mode, per HA's combo_control for RGB
|
|
626
|
+
const packet = this.buildComboPacket(
|
|
627
|
+
controllerId,
|
|
628
|
+
meshIndex,
|
|
629
|
+
on,
|
|
630
|
+
clampedBrightness,
|
|
631
|
+
254,
|
|
632
|
+
{ r, g, b },
|
|
633
|
+
seq,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
this.socket.write(packet);
|
|
637
|
+
this.log.info(
|
|
638
|
+
'[Cync TCP] Sent color combo packet: device=%s on=%s brightness=%d rgb=(%d,%d,%d) seq=%d',
|
|
639
|
+
deviceId,
|
|
640
|
+
String(on),
|
|
641
|
+
clampedBrightness,
|
|
642
|
+
r,
|
|
643
|
+
g,
|
|
644
|
+
b,
|
|
645
|
+
seq,
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
459
649
|
private findDevice(deviceId: string) {
|
|
460
650
|
for (const mesh of this.config?.meshes ?? []) {
|
|
461
651
|
for (const dev of mesh.devices ?? []) {
|
|
@@ -563,13 +753,14 @@ export class TcpClient {
|
|
|
563
753
|
// Default payload is the raw frame
|
|
564
754
|
let payload: unknown = frame;
|
|
565
755
|
|
|
566
|
-
|
|
567
|
-
|
|
756
|
+
// 0x73 / 0x83 carry per-device state updates on the LAN.
|
|
757
|
+
// Mirror the HA integration: try the topology-based parser first.
|
|
758
|
+
if (type === 0x73 || type === 0x83) {
|
|
568
759
|
const lanParsed = this.parseLanSwitchUpdate(frame);
|
|
569
760
|
if (lanParsed) {
|
|
570
761
|
payload = lanParsed;
|
|
571
|
-
} else {
|
|
572
|
-
// Fallback to legacy controller-level parsing
|
|
762
|
+
} else if (type === 0x83) {
|
|
763
|
+
// Fallback to legacy controller-level parsing only for 0x83
|
|
573
764
|
const parsed = this.parseSwitchStateFrame(frame);
|
|
574
765
|
if (parsed) {
|
|
575
766
|
const deviceId = this.controllerToDevice.get(parsed.controllerId);
|
package/src/cync/token-store.ts
CHANGED
|
@@ -13,12 +13,17 @@ export interface CyncTokenData {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Simple JSON token store under the Homebridge storage path.
|
|
16
|
+
*
|
|
17
|
+
* Files are stored at:
|
|
18
|
+
* <storagePath>/homebridge-cync-app/cync-tokens.json
|
|
16
19
|
*/
|
|
17
20
|
export class CyncTokenStore {
|
|
21
|
+
private readonly dirPath: string;
|
|
18
22
|
private readonly filePath: string;
|
|
19
23
|
|
|
20
24
|
public constructor(storagePath: string) {
|
|
21
|
-
this.
|
|
25
|
+
this.dirPath = path.join(storagePath, 'homebridge-cync-app');
|
|
26
|
+
this.filePath = path.join(this.dirPath, 'cync-tokens.json');
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
public async load(): Promise<CyncTokenData | null> {
|
|
@@ -33,12 +38,16 @@ export class CyncTokenStore {
|
|
|
33
38
|
|
|
34
39
|
return data;
|
|
35
40
|
} catch {
|
|
41
|
+
// file missing or unreadable → treat as no token
|
|
36
42
|
return null;
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
public async save(data: CyncTokenData): Promise<void> {
|
|
41
47
|
const json = JSON.stringify(data, null, 2);
|
|
48
|
+
|
|
49
|
+
// Ensure directory exists before writing
|
|
50
|
+
await fs.mkdir(this.dirPath, { recursive: true });
|
|
42
51
|
await fs.writeFile(this.filePath, json, 'utf8');
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -46,7 +55,7 @@ export class CyncTokenStore {
|
|
|
46
55
|
try {
|
|
47
56
|
await fs.unlink(this.filePath);
|
|
48
57
|
} catch {
|
|
49
|
-
// ignore if missing
|
|
58
|
+
// ignore if missing or already removed
|
|
50
59
|
}
|
|
51
60
|
}
|
|
52
61
|
}
|