homebridge-cync-app 0.1.7 → 0.1.8
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 +78 -0
- 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 +138 -0
- package/dist/cync/cync-accessory-helpers.js.map +1 -0
- package/dist/cync/cync-client.d.ts +1 -0
- package/dist/cync/cync-client.js +101 -24
- 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 +197 -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 +58 -0
- package/dist/cync/cync-switch-accessory.js.map +1 -0
- 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 +17 -381
- package/dist/platform.js.map +1 -1
- package/package.json +1 -1
- package/src/cync/config-client.ts +113 -0
- package/src/cync/cync-accessory-helpers.ts +233 -0
- package/src/cync/cync-client.ts +154 -32
- package/src/cync/cync-light-accessory.ts +359 -0
- package/src/cync/cync-switch-accessory.ts +100 -0
- package/src/cync/token-store.ts +3 -2
- package/src/platform.ts +48 -660
|
@@ -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 revision, if present
|
|
227
|
+
if (typeof rawDevice.firmwareVersion === 'string' && rawDevice.firmwareVersion.trim().length > 0) {
|
|
228
|
+
infoService.updateCharacteristic(
|
|
229
|
+
Characteristic.FirmwareRevision,
|
|
230
|
+
rawDevice.firmwareVersion.trim(),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/cync/cync-client.ts
CHANGED
|
@@ -52,6 +52,93 @@ export class CyncClient {
|
|
|
52
52
|
// Optional LAN update hook for the platform
|
|
53
53
|
private lanUpdateHandler: ((update: unknown) => void) | null = null;
|
|
54
54
|
|
|
55
|
+
// ### 🧩 Password Login Helper: background username/password login for new tokens
|
|
56
|
+
private async loginWithPasswordForToken(): Promise<CyncTokenData | null> {
|
|
57
|
+
const { username, password } = this.loginConfig;
|
|
58
|
+
|
|
59
|
+
if (!username || !password) {
|
|
60
|
+
this.log.error(
|
|
61
|
+
'CyncClient: cannot perform password login; username or password is missing from config.',
|
|
62
|
+
);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
this.log.warn(
|
|
68
|
+
'CyncClient: performing background username/password login (no OTP) to obtain a fresh token…',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const session = await this.configClient.loginWithPassword(
|
|
72
|
+
username.trim(),
|
|
73
|
+
password,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const raw = session.raw as Record<string, unknown>;
|
|
77
|
+
const authorize =
|
|
78
|
+
typeof raw.authorize === 'string' ? raw.authorize : undefined;
|
|
79
|
+
|
|
80
|
+
const s = session as unknown as SessionWithPossibleTokens;
|
|
81
|
+
const access = s.accessToken ?? s.jwt;
|
|
82
|
+
|
|
83
|
+
if (!access) {
|
|
84
|
+
this.log.error(
|
|
85
|
+
'CyncClient: password login session did not return an access token.',
|
|
86
|
+
);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const refresh = s.refreshToken ?? s.refreshJwt;
|
|
91
|
+
const expiresAt = s.expiresAt;
|
|
92
|
+
|
|
93
|
+
let lanLoginCode: string | undefined;
|
|
94
|
+
if (authorize) {
|
|
95
|
+
const userIdNum = Number.parseInt(session.userId, 10);
|
|
96
|
+
if (Number.isFinite(userIdNum) && userIdNum >= 0) {
|
|
97
|
+
const lanBlob = this.buildLanLoginCode(authorize, userIdNum);
|
|
98
|
+
lanLoginCode = Buffer.from(lanBlob).toString('base64');
|
|
99
|
+
} else {
|
|
100
|
+
this.log.warn(
|
|
101
|
+
'CyncClient: password login returned non-numeric userId=%s; LAN login code not generated.',
|
|
102
|
+
session.userId,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
this.log.warn(
|
|
107
|
+
'CyncClient: password login response missing "authorize"; LAN login may be disabled.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const next: CyncTokenData = {
|
|
112
|
+
userId: String(session.userId),
|
|
113
|
+
accessToken: access,
|
|
114
|
+
refreshToken: refresh,
|
|
115
|
+
expiresAt,
|
|
116
|
+
authorize,
|
|
117
|
+
lanLoginCode,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await this.tokenStore.save(next);
|
|
121
|
+
this.tokenData = next;
|
|
122
|
+
this.applyAccessToken(next);
|
|
123
|
+
|
|
124
|
+
this.log.info(
|
|
125
|
+
'CyncClient: obtained new token via password login; userId=%s expiresAt=%s',
|
|
126
|
+
next.userId,
|
|
127
|
+
next.expiresAt
|
|
128
|
+
? new Date(next.expiresAt).toISOString()
|
|
129
|
+
: 'unknown',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return next;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
this.log.error(
|
|
135
|
+
'CyncClient: password-based background login failed: %o',
|
|
136
|
+
err,
|
|
137
|
+
);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
55
142
|
// ### 🧩 LAN Update Bridge: allow platform to handle device updates
|
|
56
143
|
public onLanDeviceUpdate(handler: (update: unknown) => void): void {
|
|
57
144
|
this.lanUpdateHandler = handler;
|
|
@@ -323,10 +410,20 @@ export class CyncClient {
|
|
|
323
410
|
*/
|
|
324
411
|
// ### 🧩 Refresh Error Detector: identifies "Access-Token Expired" responses
|
|
325
412
|
private isAccessTokenExpiredError(err: unknown): boolean {
|
|
326
|
-
if (!err
|
|
413
|
+
if (!err) {
|
|
327
414
|
return false;
|
|
328
415
|
}
|
|
329
416
|
|
|
417
|
+
// Most common case: ConfigClient throws a plain Error('Access-Token Expired')
|
|
418
|
+
if (err instanceof Error) {
|
|
419
|
+
if (
|
|
420
|
+
err.message === 'Access-Token Expired' ||
|
|
421
|
+
err.message.includes('Access-Token Expired')
|
|
422
|
+
) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
330
427
|
type ErrorWithShape = {
|
|
331
428
|
status?: number;
|
|
332
429
|
message?: string;
|
|
@@ -338,54 +435,79 @@ export class CyncClient {
|
|
|
338
435
|
|
|
339
436
|
const e = err as ErrorWithShape;
|
|
340
437
|
|
|
341
|
-
// Shape we see in
|
|
438
|
+
// Shape we see in raw HTTP JSON:
|
|
342
439
|
// { error: { msg: 'Access-Token Expired', code: 4031021 } }
|
|
343
|
-
if (
|
|
440
|
+
if (
|
|
441
|
+
e.error &&
|
|
442
|
+
(e.error.msg === 'Access-Token Expired' || e.error.code === 4031021)
|
|
443
|
+
) {
|
|
344
444
|
return true;
|
|
345
445
|
}
|
|
346
446
|
|
|
347
|
-
// Fallback: generic 403
|
|
348
|
-
if (
|
|
447
|
+
// Fallback: generic 403 plus message text
|
|
448
|
+
if (
|
|
449
|
+
e.status === 403 &&
|
|
450
|
+
e.message &&
|
|
451
|
+
e.message.includes('Access-Token Expired')
|
|
452
|
+
) {
|
|
349
453
|
return true;
|
|
350
454
|
}
|
|
351
455
|
|
|
352
456
|
return false;
|
|
353
457
|
}
|
|
354
458
|
|
|
355
|
-
// ### 🧩 Token Refresh Helper: exchanges refreshToken for a new accessToken
|
|
356
|
-
private async refreshAccessToken(
|
|
357
|
-
|
|
459
|
+
// ### 🧩 Token Refresh Helper: exchanges refreshToken for a new accessToken, or falls back to password login
|
|
460
|
+
private async refreshAccessToken(
|
|
461
|
+
stored: CyncTokenData,
|
|
462
|
+
): Promise<CyncTokenData | null> {
|
|
463
|
+
// First, try refresh_token if we have one
|
|
464
|
+
if (stored.refreshToken) {
|
|
465
|
+
try {
|
|
466
|
+
const resp = await this.configClient.refreshAccessToken(
|
|
467
|
+
stored.refreshToken,
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
const next: CyncTokenData = {
|
|
471
|
+
...stored,
|
|
472
|
+
accessToken: resp.accessToken,
|
|
473
|
+
refreshToken: resp.refreshToken ?? stored.refreshToken,
|
|
474
|
+
expiresAt: resp.expiresAt ?? stored.expiresAt,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
await this.tokenStore.save(next);
|
|
478
|
+
this.tokenData = next;
|
|
479
|
+
this.applyAccessToken(next);
|
|
480
|
+
|
|
481
|
+
this.log.info(
|
|
482
|
+
'CyncClient: refreshed access token for userId=%s; expiresAt=%s',
|
|
483
|
+
next.userId,
|
|
484
|
+
next.expiresAt
|
|
485
|
+
? new Date(next.expiresAt).toISOString()
|
|
486
|
+
: 'unknown',
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
return next;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
this.log.error('CyncClient: token refresh failed: %o', err);
|
|
492
|
+
// fall through to password-based login below
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
358
495
|
this.log.warn(
|
|
359
|
-
'CyncClient: refreshAccessToken() called but no refreshToken is stored;
|
|
496
|
+
'CyncClient: refreshAccessToken() called but no refreshToken is stored; will attempt password-based login.',
|
|
360
497
|
);
|
|
361
|
-
return null;
|
|
362
498
|
}
|
|
363
499
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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',
|
|
500
|
+
// If we get here, either we had no refreshToken or refresh failed.
|
|
501
|
+
// Attempt a background username/password login to obtain a fresh token.
|
|
502
|
+
const viaPassword = await this.loginWithPasswordForToken();
|
|
503
|
+
if (!viaPassword) {
|
|
504
|
+
this.log.error(
|
|
505
|
+
'CyncClient: password-based background login failed; cannot refresh Cync token automatically.',
|
|
382
506
|
);
|
|
383
|
-
|
|
384
|
-
return next;
|
|
385
|
-
} catch (err) {
|
|
386
|
-
this.log.error('CyncClient: token refresh failed: %o', err);
|
|
387
507
|
return null;
|
|
388
508
|
}
|
|
509
|
+
|
|
510
|
+
return viaPassword;
|
|
389
511
|
}
|
|
390
512
|
|
|
391
513
|
// ### 🧩 Cloud Config Wrapper: auto-refreshes access token
|