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.
@@ -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
- * Very small logger interface so we can accept either the Homebridge log
70
- * object or console.* functions in tests.
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.
@@ -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.configClient.getCloudConfig();
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.configClient.getDeviceProperties(
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
+ }
@@ -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
- if (type === 0x83) {
567
- // Preferred path: HA-style per-device parsing using homeDevices + switchIdToHomeId
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);
@@ -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.filePath = path.join(storagePath, 'cync-tokens.json');
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
  }