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
|
@@ -28,6 +28,38 @@ type CyncErrorBody = {
|
|
|
28
28
|
[key: string]: unknown;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
type CyncApiError = {
|
|
32
|
+
status: number;
|
|
33
|
+
statusText: string;
|
|
34
|
+
body: unknown;
|
|
35
|
+
code?: number;
|
|
36
|
+
msg?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function extractCyncError(body: unknown): { code?: number; msg?: string } {
|
|
40
|
+
if (!body || typeof body !== 'object') {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const obj = body as Record<string, unknown>;
|
|
45
|
+
const err = obj.error;
|
|
46
|
+
|
|
47
|
+
if (!err || typeof err !== 'object') {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const e = err as Record<string, unknown>;
|
|
52
|
+
const code = typeof e.code === 'number' ? e.code : undefined;
|
|
53
|
+
const msg = typeof e.msg === 'string' ? e.msg : undefined;
|
|
54
|
+
|
|
55
|
+
return { code, msg };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isDevicePropertyNotExists(status: number, body: unknown): boolean {
|
|
59
|
+
const { code, msg } = extractCyncError(body);
|
|
60
|
+
return status === 404 && (code === 4041009 || msg === 'device property not exists');
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
export interface CyncLoginSession {
|
|
32
64
|
accessToken: string;
|
|
33
65
|
userId: string;
|
|
@@ -223,6 +255,119 @@ export class ConfigClient {
|
|
|
223
255
|
raw: json,
|
|
224
256
|
};
|
|
225
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Password-only login for background refresh.
|
|
261
|
+
*
|
|
262
|
+
* This uses the /user_auth endpoint (no two_factor) and is intended
|
|
263
|
+
* for automatic re-auth when the access token has expired, after an
|
|
264
|
+
* initial 2FA bootstrap has already been completed.
|
|
265
|
+
*/
|
|
266
|
+
public async loginWithPassword(
|
|
267
|
+
email: string,
|
|
268
|
+
password: string,
|
|
269
|
+
): Promise<CyncLoginSession & { refreshToken?: string; expiresAt?: number }> {
|
|
270
|
+
const url = `${CYNC_API_BASE}user_auth`;
|
|
271
|
+
this.log.debug('Logging into Cync with password-only auth for %s…', email);
|
|
272
|
+
|
|
273
|
+
const body = {
|
|
274
|
+
corp_id: CORP_ID,
|
|
275
|
+
email,
|
|
276
|
+
password,
|
|
277
|
+
resource: ConfigClient.randomLoginResource(),
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const res = (await fetch(url, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: {
|
|
283
|
+
'Content-Type': 'application/json',
|
|
284
|
+
},
|
|
285
|
+
body: JSON.stringify(body),
|
|
286
|
+
})) as HttpResponse;
|
|
287
|
+
|
|
288
|
+
const json: unknown = await res.json().catch(async () => {
|
|
289
|
+
const text = await res.text().catch(() => '');
|
|
290
|
+
throw new Error(`Cync password login returned non-JSON payload: ${text}`);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
if (!res.ok) {
|
|
294
|
+
this.log.error(
|
|
295
|
+
'Cync password login failed: HTTP %d %s %o',
|
|
296
|
+
res.status,
|
|
297
|
+
res.statusText,
|
|
298
|
+
json,
|
|
299
|
+
);
|
|
300
|
+
const errBody = json as CyncErrorBody;
|
|
301
|
+
throw new Error(
|
|
302
|
+
errBody.error?.msg ??
|
|
303
|
+
`Cync password login failed with status ${res.status} ${res.statusText}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const obj = json as Record<string, unknown>;
|
|
308
|
+
this.log.debug('Cync password login response: keys=%o', Object.keys(obj));
|
|
309
|
+
|
|
310
|
+
const accessTokenRaw = obj.access_token ?? obj.accessToken;
|
|
311
|
+
const userIdRaw = obj.user_id ?? obj.userId;
|
|
312
|
+
const authorizeRaw = obj.authorize;
|
|
313
|
+
const refreshTokenRaw = obj.refresh_token ?? obj.refreshToken;
|
|
314
|
+
const expiresAtRaw = obj.expires_at ?? obj.expiresAt;
|
|
315
|
+
|
|
316
|
+
const accessToken =
|
|
317
|
+
typeof accessTokenRaw === 'string' && accessTokenRaw.length > 0
|
|
318
|
+
? accessTokenRaw
|
|
319
|
+
: undefined;
|
|
320
|
+
|
|
321
|
+
const userId =
|
|
322
|
+
userIdRaw !== undefined && userIdRaw !== null
|
|
323
|
+
? String(userIdRaw)
|
|
324
|
+
: undefined;
|
|
325
|
+
|
|
326
|
+
const authorize =
|
|
327
|
+
typeof authorizeRaw === 'string' && authorizeRaw.length > 0
|
|
328
|
+
? authorizeRaw
|
|
329
|
+
: undefined;
|
|
330
|
+
|
|
331
|
+
if (!accessToken || !userId) {
|
|
332
|
+
this.log.error(
|
|
333
|
+
'Cync password login missing access_token or user_id: %o',
|
|
334
|
+
json,
|
|
335
|
+
);
|
|
336
|
+
throw new Error(
|
|
337
|
+
'Cync password login response missing access_token or user_id',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let refreshToken: string | undefined;
|
|
342
|
+
if (typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0) {
|
|
343
|
+
refreshToken = refreshTokenRaw;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let expiresAt: number | undefined;
|
|
347
|
+
if (typeof expiresAtRaw === 'number') {
|
|
348
|
+
expiresAt = expiresAtRaw;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
this.accessToken = accessToken;
|
|
352
|
+
this.userId = userId;
|
|
353
|
+
this.authorize = authorize ?? null;
|
|
354
|
+
|
|
355
|
+
this.log.info('Cync password login successful; userId=%s', userId);
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
accessToken,
|
|
359
|
+
userId,
|
|
360
|
+
authorize,
|
|
361
|
+
raw: {
|
|
362
|
+
...obj,
|
|
363
|
+
refreshToken,
|
|
364
|
+
expiresAt,
|
|
365
|
+
},
|
|
366
|
+
refreshToken,
|
|
367
|
+
expiresAt,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
226
371
|
/**
|
|
227
372
|
* Refresh the access token using a stored refresh token.
|
|
228
373
|
*
|
|
@@ -412,19 +557,37 @@ export class ConfigClient {
|
|
|
412
557
|
});
|
|
413
558
|
|
|
414
559
|
if (!res.ok) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
560
|
+
const { code, msg } = extractCyncError(json);
|
|
561
|
+
const outMsg = msg ?? `Cync properties failed with ${res.status}`;
|
|
562
|
+
|
|
563
|
+
if (isDevicePropertyNotExists(res.status, json)) {
|
|
564
|
+
this.log.debug(
|
|
565
|
+
'Cync properties call failed (expected): HTTP %d %s code=%s msg=%s',
|
|
566
|
+
res.status,
|
|
567
|
+
res.statusText,
|
|
568
|
+
code !== undefined ? String(code) : 'unknown',
|
|
569
|
+
outMsg,
|
|
570
|
+
);
|
|
571
|
+
} else {
|
|
572
|
+
this.log.error(
|
|
573
|
+
'Cync properties call failed: HTTP %d %s %o',
|
|
574
|
+
res.status,
|
|
575
|
+
res.statusText,
|
|
576
|
+
json,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const e: CyncApiError = {
|
|
581
|
+
status: res.status,
|
|
582
|
+
statusText: res.statusText,
|
|
583
|
+
body: json,
|
|
584
|
+
code,
|
|
585
|
+
msg: outMsg,
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
throw e;
|
|
425
589
|
}
|
|
426
590
|
|
|
427
|
-
// We keep this as a loose record; callers can shape it as needed.
|
|
428
591
|
return json as Record<string, unknown>;
|
|
429
592
|
}
|
|
430
593
|
|
|
@@ -446,7 +609,7 @@ export class ConfigClient {
|
|
|
446
609
|
throw new Error('Cync session not initialised. Call loginWithTwoFactor() first.');
|
|
447
610
|
}
|
|
448
611
|
}
|
|
449
|
-
//
|
|
612
|
+
// LAN Login Blob Builder: Generates the auth_code payload used by Cync LAN TCP
|
|
450
613
|
public static buildLanLoginCode(userId: string, authorize: string): Uint8Array {
|
|
451
614
|
const authBytes = Buffer.from(authorize, 'ascii');
|
|
452
615
|
const lengthByte = 10 + authBytes.length;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// src/cync/cync-accessory-helpers.ts
|
|
2
|
+
import type {
|
|
3
|
+
API,
|
|
4
|
+
Logger,
|
|
5
|
+
PlatformAccessory,
|
|
6
|
+
} from 'homebridge';
|
|
7
|
+
|
|
8
|
+
import type { CyncDevice } from './config-client.js';
|
|
9
|
+
import type { TcpClient } from './tcp-client.js';
|
|
10
|
+
import { lookupDeviceModel } from './device-catalog.js';
|
|
11
|
+
|
|
12
|
+
// Narrowed view of the Cync device properties returned by getDeviceProperties()
|
|
13
|
+
type CyncDeviceRaw = {
|
|
14
|
+
displayName?: string;
|
|
15
|
+
firmwareVersion?: string;
|
|
16
|
+
mac?: string;
|
|
17
|
+
wifiMac?: string;
|
|
18
|
+
deviceType?: number;
|
|
19
|
+
deviceID?: number;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// CyncDevice as seen by the platform, possibly enriched with a `raw` block
|
|
24
|
+
type CyncDeviceWithRaw = CyncDevice & {
|
|
25
|
+
raw?: CyncDeviceRaw;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Context stored on the accessory
|
|
29
|
+
export interface CyncAccessoryContext {
|
|
30
|
+
cync?: {
|
|
31
|
+
meshId: string;
|
|
32
|
+
deviceId: string;
|
|
33
|
+
productId?: string;
|
|
34
|
+
|
|
35
|
+
on?: boolean;
|
|
36
|
+
brightness?: number; // 0–100 (LAN "level")
|
|
37
|
+
|
|
38
|
+
// Color state (local cache, not yet read from LAN frames)
|
|
39
|
+
hue?: number; // 0–360
|
|
40
|
+
saturation?: number; // 0–100
|
|
41
|
+
rgb?: { r: number; g: number; b: number };
|
|
42
|
+
colorActive?: boolean; // true if we last set RGB color
|
|
43
|
+
};
|
|
44
|
+
[key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Minimal runtime “env” that accessory modules need from the platform
|
|
48
|
+
export interface CyncAccessoryEnv {
|
|
49
|
+
log: Logger;
|
|
50
|
+
api: API;
|
|
51
|
+
tcpClient: TcpClient;
|
|
52
|
+
|
|
53
|
+
isDeviceProbablyOffline(deviceId: string): boolean;
|
|
54
|
+
markDeviceSeen(deviceId: string): void;
|
|
55
|
+
startPollingDevice(deviceId: string): void;
|
|
56
|
+
registerAccessoryForDevice(deviceId: string, accessory: PlatformAccessory): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* HSV (HomeKit style) → RGB helper used by color lights.
|
|
61
|
+
*/
|
|
62
|
+
export function hsvToRgb(hue: number, saturation: number, value: number): { r: number; g: number; b: number } {
|
|
63
|
+
const h = ((hue % 360) + 360) % 360;
|
|
64
|
+
const s = Math.max(0, Math.min(100, saturation)) / 100;
|
|
65
|
+
const v = Math.max(0, Math.min(100, value)) / 100;
|
|
66
|
+
|
|
67
|
+
if (s === 0) {
|
|
68
|
+
const grey = Math.round(v * 255);
|
|
69
|
+
return { r: grey, g: grey, b: grey };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sector = h / 60;
|
|
73
|
+
const i = Math.floor(sector);
|
|
74
|
+
const f = sector - i;
|
|
75
|
+
|
|
76
|
+
const p = v * (1 - s);
|
|
77
|
+
const q = v * (1 - s * f);
|
|
78
|
+
const t = v * (1 - s * (1 - f));
|
|
79
|
+
|
|
80
|
+
let r = 0;
|
|
81
|
+
let g = 0;
|
|
82
|
+
let b = 0;
|
|
83
|
+
|
|
84
|
+
switch (i) {
|
|
85
|
+
case 0:
|
|
86
|
+
r = v;
|
|
87
|
+
g = t;
|
|
88
|
+
b = p;
|
|
89
|
+
break;
|
|
90
|
+
case 1:
|
|
91
|
+
r = q;
|
|
92
|
+
g = v;
|
|
93
|
+
b = p;
|
|
94
|
+
break;
|
|
95
|
+
case 2:
|
|
96
|
+
r = p;
|
|
97
|
+
g = v;
|
|
98
|
+
b = t;
|
|
99
|
+
break;
|
|
100
|
+
case 3:
|
|
101
|
+
r = p;
|
|
102
|
+
g = q;
|
|
103
|
+
b = v;
|
|
104
|
+
break;
|
|
105
|
+
case 4:
|
|
106
|
+
r = t;
|
|
107
|
+
g = p;
|
|
108
|
+
b = v;
|
|
109
|
+
break;
|
|
110
|
+
default:
|
|
111
|
+
r = v;
|
|
112
|
+
g = p;
|
|
113
|
+
b = q;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
r: Math.round(r * 255),
|
|
119
|
+
g: Math.round(g * 255),
|
|
120
|
+
b: Math.round(b * 255),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the numeric device type from cloud + raw device shape.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveDeviceType(device: CyncDevice): number | undefined {
|
|
128
|
+
const typedDevice = device as unknown as {
|
|
129
|
+
device_type?: number;
|
|
130
|
+
raw?: { deviceType?: number | string };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (typeof typedDevice.device_type === 'number') {
|
|
134
|
+
return typedDevice.device_type;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rawType = typedDevice.raw?.deviceType;
|
|
138
|
+
if (typeof rawType === 'number') {
|
|
139
|
+
return rawType;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (typeof rawType === 'string' && rawType.trim() !== '') {
|
|
143
|
+
const parsed = Number(rawType.trim());
|
|
144
|
+
if (!Number.isNaN(parsed)) {
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Populate the standard Accessory Information service with Cync metadata.
|
|
154
|
+
*/
|
|
155
|
+
export function applyAccessoryInformationFromCyncDevice(
|
|
156
|
+
api: API,
|
|
157
|
+
accessory: PlatformAccessory,
|
|
158
|
+
device: CyncDevice,
|
|
159
|
+
deviceName: string,
|
|
160
|
+
deviceId: string,
|
|
161
|
+
): void {
|
|
162
|
+
const infoService = accessory.getService(api.hap.Service.AccessoryInformation);
|
|
163
|
+
if (!infoService) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const Characteristic = api.hap.Characteristic;
|
|
168
|
+
const deviceWithRaw = device as CyncDeviceWithRaw;
|
|
169
|
+
const rawDevice = deviceWithRaw.raw ?? {};
|
|
170
|
+
|
|
171
|
+
// Name: keep in sync with how we present the accessory
|
|
172
|
+
const name = deviceName || accessory.displayName;
|
|
173
|
+
infoService.updateCharacteristic(Characteristic.Name, name);
|
|
174
|
+
|
|
175
|
+
// Manufacturer: fixed for all Cync devices
|
|
176
|
+
infoService.updateCharacteristic(Characteristic.Manufacturer, 'GE Lighting');
|
|
177
|
+
|
|
178
|
+
// Model: prefer catalog entry (Cync app-style model name), fall back to raw info
|
|
179
|
+
const resolvedType = resolveDeviceType(device);
|
|
180
|
+
const catalogEntry = typeof resolvedType === 'number'
|
|
181
|
+
? lookupDeviceModel(resolvedType)
|
|
182
|
+
: undefined;
|
|
183
|
+
|
|
184
|
+
let model: string;
|
|
185
|
+
|
|
186
|
+
if (catalogEntry) {
|
|
187
|
+
// Use the Cync app-style model name
|
|
188
|
+
model = catalogEntry.modelName;
|
|
189
|
+
|
|
190
|
+
// Persist for debugging / future use
|
|
191
|
+
const ctx = accessory.context as Record<string, unknown>;
|
|
192
|
+
ctx.deviceType = resolvedType;
|
|
193
|
+
ctx.modelName = catalogEntry.modelName;
|
|
194
|
+
if (catalogEntry.marketingName) {
|
|
195
|
+
ctx.marketingName = catalogEntry.marketingName;
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
// Fallback: use device displayName + type
|
|
199
|
+
const modelBase =
|
|
200
|
+
typeof rawDevice.displayName === 'string' && rawDevice.displayName.trim().length > 0
|
|
201
|
+
? rawDevice.displayName.trim()
|
|
202
|
+
: 'Cync Device';
|
|
203
|
+
|
|
204
|
+
const modelSuffix =
|
|
205
|
+
typeof resolvedType === 'number'
|
|
206
|
+
? ` (Type ${resolvedType})`
|
|
207
|
+
: '';
|
|
208
|
+
|
|
209
|
+
model = modelBase + modelSuffix;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
infoService.updateCharacteristic(Characteristic.Model, model);
|
|
213
|
+
|
|
214
|
+
// Serial: prefer wifiMac, then mac, then deviceID, then the string deviceId
|
|
215
|
+
const serial =
|
|
216
|
+
(typeof rawDevice.wifiMac === 'string' && rawDevice.wifiMac.trim().length > 0)
|
|
217
|
+
? rawDevice.wifiMac.trim()
|
|
218
|
+
: (typeof rawDevice.mac === 'string' && rawDevice.mac.trim().length > 0)
|
|
219
|
+
? rawDevice.mac.trim()
|
|
220
|
+
: (rawDevice.deviceID !== undefined
|
|
221
|
+
? String(rawDevice.deviceID)
|
|
222
|
+
: deviceId);
|
|
223
|
+
|
|
224
|
+
infoService.updateCharacteristic(Characteristic.SerialNumber, serial);
|
|
225
|
+
|
|
226
|
+
// Firmware / Software revision
|
|
227
|
+
if (typeof rawDevice.firmwareVersion === 'string' && rawDevice.firmwareVersion.trim().length > 0) {
|
|
228
|
+
const rev = rawDevice.firmwareVersion.trim();
|
|
229
|
+
|
|
230
|
+
infoService.updateCharacteristic(Characteristic.FirmwareRevision, rev);
|
|
231
|
+
infoService.updateCharacteristic(Characteristic.SoftwareRevision, rev);
|
|
232
|
+
}
|
|
233
|
+
}
|