homebridge-cync-app 0.1.6 → 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/.github/ISSUE_TEMPLATE/add-support-for-a-new-cync-device.md +139 -0
- package/CHANGELOG.md +21 -0
- package/README.md +3 -1
- package/dist/cync/config-client.d.ts +23 -0
- package/dist/cync/config-client.js +133 -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 +5 -0
- package/dist/cync/cync-client.js +156 -2
- 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/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/token-store.js +2 -2
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +8 -3
- package/dist/platform.js +49 -320
- package/dist/platform.js.map +1 -1
- package/package.json +1 -1
- package/src/cync/config-client.ts +192 -0
- package/src/cync/cync-accessory-helpers.ts +233 -0
- package/src/cync/cync-client.ts +238 -2
- package/src/cync/cync-light-accessory.ts +359 -0
- package/src/cync/cync-switch-accessory.ts +100 -0
- package/src/cync/device-catalog.ts +55 -0
- package/src/cync/token-store.ts +3 -2
- package/src/platform.ts +87 -549
|
@@ -64,6 +64,11 @@ export interface CyncCloudConfig {
|
|
|
64
64
|
meshes: CyncDeviceMesh[];
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export interface CyncRefreshResponse {
|
|
68
|
+
accessToken: string;
|
|
69
|
+
refreshToken?: string;
|
|
70
|
+
expiresAt?: number;
|
|
71
|
+
}
|
|
67
72
|
|
|
68
73
|
export interface CyncLogger {
|
|
69
74
|
debug(message: string, ...args: unknown[]): void;
|
|
@@ -219,6 +224,193 @@ export class ConfigClient {
|
|
|
219
224
|
};
|
|
220
225
|
}
|
|
221
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Password-only login for background refresh.
|
|
229
|
+
*
|
|
230
|
+
* This uses the /user_auth endpoint (no two_factor) and is intended
|
|
231
|
+
* for automatic re-auth when the access token has expired, after an
|
|
232
|
+
* initial 2FA bootstrap has already been completed.
|
|
233
|
+
*/
|
|
234
|
+
public async loginWithPassword(
|
|
235
|
+
email: string,
|
|
236
|
+
password: string,
|
|
237
|
+
): Promise<CyncLoginSession & { refreshToken?: string; expiresAt?: number }> {
|
|
238
|
+
const url = `${CYNC_API_BASE}user_auth`;
|
|
239
|
+
this.log.debug('Logging into Cync with password-only auth for %s…', email);
|
|
240
|
+
|
|
241
|
+
const body = {
|
|
242
|
+
corp_id: CORP_ID,
|
|
243
|
+
email,
|
|
244
|
+
password,
|
|
245
|
+
resource: ConfigClient.randomLoginResource(),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const res = (await fetch(url, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
},
|
|
253
|
+
body: JSON.stringify(body),
|
|
254
|
+
})) as HttpResponse;
|
|
255
|
+
|
|
256
|
+
const json: unknown = await res.json().catch(async () => {
|
|
257
|
+
const text = await res.text().catch(() => '');
|
|
258
|
+
throw new Error(`Cync password login returned non-JSON payload: ${text}`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (!res.ok) {
|
|
262
|
+
this.log.error(
|
|
263
|
+
'Cync password login failed: HTTP %d %s %o',
|
|
264
|
+
res.status,
|
|
265
|
+
res.statusText,
|
|
266
|
+
json,
|
|
267
|
+
);
|
|
268
|
+
const errBody = json as CyncErrorBody;
|
|
269
|
+
throw new Error(
|
|
270
|
+
errBody.error?.msg ??
|
|
271
|
+
`Cync password login failed with status ${res.status} ${res.statusText}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const obj = json as Record<string, unknown>;
|
|
276
|
+
this.log.debug('Cync password login response: keys=%o', Object.keys(obj));
|
|
277
|
+
|
|
278
|
+
const accessTokenRaw = obj.access_token ?? obj.accessToken;
|
|
279
|
+
const userIdRaw = obj.user_id ?? obj.userId;
|
|
280
|
+
const authorizeRaw = obj.authorize;
|
|
281
|
+
const refreshTokenRaw = obj.refresh_token ?? obj.refreshToken;
|
|
282
|
+
const expiresAtRaw = obj.expires_at ?? obj.expiresAt;
|
|
283
|
+
|
|
284
|
+
const accessToken =
|
|
285
|
+
typeof accessTokenRaw === 'string' && accessTokenRaw.length > 0
|
|
286
|
+
? accessTokenRaw
|
|
287
|
+
: undefined;
|
|
288
|
+
|
|
289
|
+
const userId =
|
|
290
|
+
userIdRaw !== undefined && userIdRaw !== null
|
|
291
|
+
? String(userIdRaw)
|
|
292
|
+
: undefined;
|
|
293
|
+
|
|
294
|
+
const authorize =
|
|
295
|
+
typeof authorizeRaw === 'string' && authorizeRaw.length > 0
|
|
296
|
+
? authorizeRaw
|
|
297
|
+
: undefined;
|
|
298
|
+
|
|
299
|
+
if (!accessToken || !userId) {
|
|
300
|
+
this.log.error(
|
|
301
|
+
'Cync password login missing access_token or user_id: %o',
|
|
302
|
+
json,
|
|
303
|
+
);
|
|
304
|
+
throw new Error(
|
|
305
|
+
'Cync password login response missing access_token or user_id',
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let refreshToken: string | undefined;
|
|
310
|
+
if (typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0) {
|
|
311
|
+
refreshToken = refreshTokenRaw;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let expiresAt: number | undefined;
|
|
315
|
+
if (typeof expiresAtRaw === 'number') {
|
|
316
|
+
expiresAt = expiresAtRaw;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.accessToken = accessToken;
|
|
320
|
+
this.userId = userId;
|
|
321
|
+
this.authorize = authorize ?? null;
|
|
322
|
+
|
|
323
|
+
this.log.info('Cync password login successful; userId=%s', userId);
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
accessToken,
|
|
327
|
+
userId,
|
|
328
|
+
authorize,
|
|
329
|
+
raw: {
|
|
330
|
+
...obj,
|
|
331
|
+
refreshToken,
|
|
332
|
+
expiresAt,
|
|
333
|
+
},
|
|
334
|
+
refreshToken,
|
|
335
|
+
expiresAt,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Refresh the access token using a stored refresh token.
|
|
341
|
+
*
|
|
342
|
+
* This mirrors the 2FA login flow in shape, but only exchanges the
|
|
343
|
+
* refresh_token for a new access_token (and possibly a new refresh_token).
|
|
344
|
+
*/
|
|
345
|
+
public async refreshAccessToken(refreshToken: string): Promise<CyncRefreshResponse> {
|
|
346
|
+
const url = `${CYNC_API_BASE}user_auth/refresh`;
|
|
347
|
+
this.log.debug('Refreshing Cync access token…');
|
|
348
|
+
|
|
349
|
+
const body = {
|
|
350
|
+
corp_id: CORP_ID,
|
|
351
|
+
refresh_token: refreshToken,
|
|
352
|
+
resource: ConfigClient.randomLoginResource(),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const res = (await fetch(url, {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
headers: {
|
|
358
|
+
'Content-Type': 'application/json',
|
|
359
|
+
},
|
|
360
|
+
body: JSON.stringify(body),
|
|
361
|
+
})) as HttpResponse;
|
|
362
|
+
|
|
363
|
+
const json: unknown = await res.json().catch(async () => {
|
|
364
|
+
const text = await res.text().catch(() => '');
|
|
365
|
+
throw new Error(`Cync refresh returned non-JSON payload: ${text}`);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!res.ok) {
|
|
369
|
+
this.log.error(
|
|
370
|
+
'Cync refresh failed: HTTP %d %s %o',
|
|
371
|
+
res.status,
|
|
372
|
+
res.statusText,
|
|
373
|
+
json,
|
|
374
|
+
);
|
|
375
|
+
const errBody = json as CyncErrorBody;
|
|
376
|
+
const msg =
|
|
377
|
+
errBody.error?.msg ??
|
|
378
|
+
`Cync refresh failed with status ${res.status} ${res.statusText}`;
|
|
379
|
+
throw new Error(msg);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const obj = json as Record<string, unknown>;
|
|
383
|
+
this.log.debug('Cync refresh response: keys=%o', Object.keys(obj));
|
|
384
|
+
|
|
385
|
+
const accessTokenRaw = obj.access_token ?? obj.accessToken;
|
|
386
|
+
const refreshTokenRaw = obj.refresh_token ?? obj.refreshToken;
|
|
387
|
+
const expiresAtRaw = obj.expires_at ?? obj.expiresAt;
|
|
388
|
+
|
|
389
|
+
const accessToken =
|
|
390
|
+
typeof accessTokenRaw === 'string' && accessTokenRaw.length > 0
|
|
391
|
+
? accessTokenRaw
|
|
392
|
+
: undefined;
|
|
393
|
+
|
|
394
|
+
if (!accessToken) {
|
|
395
|
+
this.log.error('Cync refresh missing access_token: %o', json);
|
|
396
|
+
throw new Error('Cync refresh response missing access_token');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const next: CyncRefreshResponse = {
|
|
400
|
+
accessToken,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
if (typeof refreshTokenRaw === 'string' && refreshTokenRaw.length > 0) {
|
|
404
|
+
next.refreshToken = refreshTokenRaw;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (typeof expiresAtRaw === 'number') {
|
|
408
|
+
next.expiresAt = expiresAtRaw;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return next;
|
|
412
|
+
}
|
|
413
|
+
|
|
222
414
|
/**
|
|
223
415
|
* Fetch the list of meshes/devices for the current user from the cloud.
|
|
224
416
|
*
|
|
@@ -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;
|
|
@@ -321,6 +408,155 @@ export class CyncClient {
|
|
|
321
408
|
* // user reads email, gets code…
|
|
322
409
|
* await client.submitTwoFactor(username, password, code); // completes login
|
|
323
410
|
*/
|
|
411
|
+
// ### 🧩 Refresh Error Detector: identifies "Access-Token Expired" responses
|
|
412
|
+
private isAccessTokenExpiredError(err: unknown): boolean {
|
|
413
|
+
if (!err) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
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
|
+
|
|
427
|
+
type ErrorWithShape = {
|
|
428
|
+
status?: number;
|
|
429
|
+
message?: string;
|
|
430
|
+
error?: {
|
|
431
|
+
msg?: string;
|
|
432
|
+
code?: number;
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const e = err as ErrorWithShape;
|
|
437
|
+
|
|
438
|
+
// Shape we see in raw HTTP JSON:
|
|
439
|
+
// { error: { msg: 'Access-Token Expired', code: 4031021 } }
|
|
440
|
+
if (
|
|
441
|
+
e.error &&
|
|
442
|
+
(e.error.msg === 'Access-Token Expired' || e.error.code === 4031021)
|
|
443
|
+
) {
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Fallback: generic 403 plus message text
|
|
448
|
+
if (
|
|
449
|
+
e.status === 403 &&
|
|
450
|
+
e.message &&
|
|
451
|
+
e.message.includes('Access-Token Expired')
|
|
452
|
+
) {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
|
|
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 {
|
|
495
|
+
this.log.warn(
|
|
496
|
+
'CyncClient: refreshAccessToken() called but no refreshToken is stored; will attempt password-based login.',
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
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.',
|
|
506
|
+
);
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return viaPassword;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ### 🧩 Cloud Config Wrapper: auto-refreshes access token
|
|
514
|
+
private async getCloudConfigWithRefresh(): Promise<CyncCloudConfig> {
|
|
515
|
+
try {
|
|
516
|
+
return await this.configClient.getCloudConfig();
|
|
517
|
+
} catch (err) {
|
|
518
|
+
if (this.isAccessTokenExpiredError(err) && this.tokenData) {
|
|
519
|
+
this.log.warn(
|
|
520
|
+
'CyncClient: access token expired when calling getCloudConfig(); refreshing and retrying once.',
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const refreshed = await this.refreshAccessToken(this.tokenData);
|
|
524
|
+
if (refreshed) {
|
|
525
|
+
return await this.configClient.getCloudConfig();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
throw err;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ### 🧩 Device Properties Wrapper: auto-refresh on Access-Token Expired for mesh calls
|
|
534
|
+
private async getDevicePropertiesWithRefresh(
|
|
535
|
+
productId: string | number,
|
|
536
|
+
meshId: string | number,
|
|
537
|
+
): Promise<Record<string, unknown>> {
|
|
538
|
+
// Normalise to strings for ConfigClient
|
|
539
|
+
const productIdStr = String(productId);
|
|
540
|
+
const meshIdStr = String(meshId);
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
return await this.configClient.getDeviceProperties(productIdStr, meshIdStr);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (this.isAccessTokenExpiredError(err) && this.tokenData) {
|
|
546
|
+
this.log.warn(
|
|
547
|
+
'CyncClient: access token expired when calling getDeviceProperties(); refreshing and retrying once.',
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
const refreshed = await this.refreshAccessToken(this.tokenData);
|
|
551
|
+
if (refreshed) {
|
|
552
|
+
return await this.configClient.getDeviceProperties(productIdStr, meshIdStr);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
324
560
|
public async authenticate(username: string): Promise<void> {
|
|
325
561
|
const email = username.trim();
|
|
326
562
|
|
|
@@ -381,7 +617,7 @@ export class CyncClient {
|
|
|
381
617
|
this.ensureSession();
|
|
382
618
|
|
|
383
619
|
this.log.info('CyncClient: loading Cync cloud configuration…');
|
|
384
|
-
const cfg = await this.
|
|
620
|
+
const cfg = await this.getCloudConfigWithRefresh();
|
|
385
621
|
|
|
386
622
|
// Reset LAN topology caches on each reload
|
|
387
623
|
this.homeDevices = {};
|
|
@@ -405,7 +641,7 @@ export class CyncClient {
|
|
|
405
641
|
const homeControllers: number[] = [];
|
|
406
642
|
|
|
407
643
|
try {
|
|
408
|
-
const props = await this.
|
|
644
|
+
const props = await this.getDevicePropertiesWithRefresh(
|
|
409
645
|
mesh.product_id,
|
|
410
646
|
mesh.id,
|
|
411
647
|
);
|