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.
@@ -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
+ }
@@ -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 || typeof err !== 'object') {
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 logs:
438
+ // Shape we see in raw HTTP JSON:
342
439
  // { error: { msg: 'Access-Token Expired', code: 4031021 } }
343
- if (e.error && (e.error.msg === 'Access-Token Expired' || e.error.code === 4031021)) {
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 with message string
348
- if (e.status === 403 && e.message && e.message.includes('Access-Token Expired')) {
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(stored: CyncTokenData): Promise<CyncTokenData | null> {
357
- if (!stored.refreshToken) {
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; cannot refresh.',
496
+ 'CyncClient: refreshAccessToken() called but no refreshToken is stored; will attempt password-based login.',
360
497
  );
361
- return null;
362
498
  }
363
499
 
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',
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