homebridge-cync-app 0.0.2
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/.editorconfig +10 -0
- package/CHANGELOG.md +33 -0
- package/LICENSE +176 -0
- package/README.md +67 -0
- package/config.schema.json +39 -0
- package/dist/cync/config-client.d.ts +77 -0
- package/dist/cync/config-client.js +222 -0
- package/dist/cync/config-client.js.map +1 -0
- package/dist/cync/cync-client.d.ts +76 -0
- package/dist/cync/cync-client.js +236 -0
- package/dist/cync/cync-client.js.map +1 -0
- package/dist/cync/tcp-client.d.ts +33 -0
- package/dist/cync/tcp-client.js +59 -0
- package/dist/cync/tcp-client.js.map +1 -0
- package/dist/cync/token-store.d.ts +16 -0
- package/dist/cync/token-store.js +39 -0
- package/dist/cync/token-store.js.map +1 -0
- package/dist/cync/types.d.ts +1 -0
- package/dist/cync/types.js +2 -0
- package/dist/cync/types.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +29 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +13 -0
- package/dist/platformAccessory.js +17 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +3 -0
- package/dist/settings.js.map +1 -0
- package/docs/cync-api-notes.md +168 -0
- package/docs/cync-client-contract.md +172 -0
- package/docs/cync-device-model.md +129 -0
- package/eslint.config.js +41 -0
- package/homebridge-cync-app-v0.0.1.zip +0 -0
- package/nodemon.json +12 -0
- package/package.json +56 -0
- package/src/@types/homebridge-lib.d.ts +14 -0
- package/src/cync/config-client.ts +370 -0
- package/src/cync/cync-client.ts +408 -0
- package/src/cync/tcp-client.ts +88 -0
- package/src/cync/token-store.ts +50 -0
- package/src/cync/types.ts +0 -0
- package/src/index.ts +12 -0
- package/src/platform.ts +209 -0
- package/src/platformAccessory.ts +18 -0
- package/src/settings.ts +3 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// src/cync/cync-client.ts
|
|
2
|
+
import {
|
|
3
|
+
ConfigClient,
|
|
4
|
+
CyncCloudConfig,
|
|
5
|
+
CyncLoginSession,
|
|
6
|
+
CyncLogger,
|
|
7
|
+
} from './config-client.js';
|
|
8
|
+
import { TcpClient } from './tcp-client.js';
|
|
9
|
+
import { CyncTokenStore, CyncTokenData } from './token-store.js';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
type SessionWithPossibleTokens = {
|
|
13
|
+
accessToken?: string;
|
|
14
|
+
jwt?: string;
|
|
15
|
+
refreshToken?: string;
|
|
16
|
+
refreshJwt?: string;
|
|
17
|
+
expiresAt?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const defaultLogger: CyncLogger = {
|
|
21
|
+
debug: (...args: unknown[]) => console.debug('[cync-client]', ...args),
|
|
22
|
+
info: (...args: unknown[]) => console.info('[cync-client]', ...args),
|
|
23
|
+
warn: (...args: unknown[]) => console.warn('[cync-client]', ...args),
|
|
24
|
+
error: (...args: unknown[]) => console.error('[cync-client]', ...args),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class CyncClient {
|
|
28
|
+
private readonly log: CyncLogger;
|
|
29
|
+
private readonly configClient: ConfigClient;
|
|
30
|
+
private readonly tcpClient: TcpClient;
|
|
31
|
+
|
|
32
|
+
private readonly tokenStore: CyncTokenStore;
|
|
33
|
+
private tokenData: CyncTokenData | null = null;
|
|
34
|
+
|
|
35
|
+
// Populated after successful login.
|
|
36
|
+
private session: CyncLoginSession | null = null;
|
|
37
|
+
private cloudConfig: CyncCloudConfig | null = null;
|
|
38
|
+
|
|
39
|
+
// Credentials from config.json, used to drive 2FA bootstrap.
|
|
40
|
+
private readonly loginConfig: { email: string; password: string; twoFactor?: string };
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
configClient: ConfigClient,
|
|
44
|
+
tcpClient: TcpClient,
|
|
45
|
+
loginConfig: { email: string; password: string; twoFactor?: string },
|
|
46
|
+
storagePath: string,
|
|
47
|
+
logger?: CyncLogger,
|
|
48
|
+
) {
|
|
49
|
+
this.configClient = configClient;
|
|
50
|
+
this.tcpClient = tcpClient;
|
|
51
|
+
this.log = logger ?? defaultLogger;
|
|
52
|
+
|
|
53
|
+
this.loginConfig = loginConfig;
|
|
54
|
+
this.tokenStore = new CyncTokenStore(storagePath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ensure we are logged in:
|
|
59
|
+
* 1) Try stored token.
|
|
60
|
+
* 2) If none/invalid, run 2FA flow (request or complete).
|
|
61
|
+
* Returns true on successful login, false if we need user input (2FA).
|
|
62
|
+
*/
|
|
63
|
+
public async ensureLoggedIn(): Promise<boolean> {
|
|
64
|
+
// 1) Try stored token/session
|
|
65
|
+
const stored = await this.tokenStore.load();
|
|
66
|
+
if (stored) {
|
|
67
|
+
this.log.info(
|
|
68
|
+
'CyncClient: using stored token for userId=%s (expiresAt=%s)',
|
|
69
|
+
stored.userId,
|
|
70
|
+
stored.expiresAt ? new Date(stored.expiresAt).toISOString() : 'unknown',
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
this.tokenData = stored;
|
|
74
|
+
|
|
75
|
+
// Hydrate ConfigClient + session snapshot.
|
|
76
|
+
this.applyAccessToken(stored);
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 2) No stored token – run 2FA bootstrap
|
|
82
|
+
const { email, password, twoFactor } = this.loginConfig;
|
|
83
|
+
|
|
84
|
+
if (!email || !password) {
|
|
85
|
+
this.log.error('CyncClient: email and password are required to obtain a new token.');
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!twoFactor || String(twoFactor).trim() === '') {
|
|
90
|
+
// No 2FA code – request one
|
|
91
|
+
this.log.info('Cync: starting 2FA handshake for %s', email);
|
|
92
|
+
await this.requestTwoFactorCode(email);
|
|
93
|
+
this.log.info(
|
|
94
|
+
'Cync: 2FA code sent to your email. Enter the code as "twoFactor" in the plugin config and restart Homebridge to complete login.',
|
|
95
|
+
);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// We have a 2FA code – complete login and persist token
|
|
100
|
+
this.log.info('Cync: completing 2FA login for %s', email);
|
|
101
|
+
const loginResult = await this.completeTwoFactorLogin(
|
|
102
|
+
email,
|
|
103
|
+
password,
|
|
104
|
+
String(twoFactor).trim(),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const tokenData: CyncTokenData = {
|
|
108
|
+
userId: String(loginResult.userId),
|
|
109
|
+
accessToken: loginResult.accessToken,
|
|
110
|
+
refreshToken: loginResult.refreshToken,
|
|
111
|
+
expiresAt: loginResult.expiresAt ?? undefined,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
await this.tokenStore.save(tokenData);
|
|
115
|
+
this.tokenData = tokenData;
|
|
116
|
+
|
|
117
|
+
// Hydrate ConfigClient + session snapshot from the freshly obtained token.
|
|
118
|
+
this.applyAccessToken(tokenData);
|
|
119
|
+
|
|
120
|
+
this.log.info('Cync login successful; userId=%s (token stored)', tokenData.userId);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Internal helper: request a 2FA email code using existing authenticate().
|
|
127
|
+
*/
|
|
128
|
+
private async requestTwoFactorCode(email: string): Promise<void> {
|
|
129
|
+
await this.authenticate(email);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Internal helper: complete 2FA login using existing submitTwoFactor().
|
|
134
|
+
* This converts CyncLoginSession into the richer shape we want for token storage.
|
|
135
|
+
*/
|
|
136
|
+
private async completeTwoFactorLogin(
|
|
137
|
+
email: string,
|
|
138
|
+
password: string,
|
|
139
|
+
code: string,
|
|
140
|
+
): Promise<
|
|
141
|
+
CyncLoginSession & {
|
|
142
|
+
accessToken: string;
|
|
143
|
+
refreshToken?: string;
|
|
144
|
+
expiresAt?: number;
|
|
145
|
+
}
|
|
146
|
+
> {
|
|
147
|
+
const session = await this.submitTwoFactor(email, password, code);
|
|
148
|
+
|
|
149
|
+
const s = session as unknown as SessionWithPossibleTokens;
|
|
150
|
+
|
|
151
|
+
const access = s.accessToken ?? s.jwt;
|
|
152
|
+
if (!access) {
|
|
153
|
+
throw new Error('CyncClient: login session did not return an access token.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...session,
|
|
158
|
+
accessToken: access,
|
|
159
|
+
refreshToken: s.refreshToken ?? s.refreshJwt,
|
|
160
|
+
expiresAt: s.expiresAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Apply an access token (and associated metadata) to the underlying ConfigClient,
|
|
166
|
+
* and hydrate our local session snapshot so ensureSession() passes.
|
|
167
|
+
*/
|
|
168
|
+
private applyAccessToken(tokenData: CyncTokenData): void {
|
|
169
|
+
if (!tokenData.accessToken || !tokenData.userId) {
|
|
170
|
+
this.log.warn(
|
|
171
|
+
'CyncClient: applyAccessToken called with missing userId or accessToken; tokenData=%o',
|
|
172
|
+
{
|
|
173
|
+
userId: tokenData.userId,
|
|
174
|
+
hasAccessToken: !!tokenData.accessToken,
|
|
175
|
+
expiresAt: tokenData.expiresAt,
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Push into ConfigClient so cloud calls can use it.
|
|
182
|
+
this.configClient.restoreSession(tokenData.accessToken, tokenData.userId);
|
|
183
|
+
|
|
184
|
+
// Hydrate our own session snapshot so ensureSession() passes.
|
|
185
|
+
this.session = {
|
|
186
|
+
accessToken: tokenData.accessToken,
|
|
187
|
+
userId: tokenData.userId,
|
|
188
|
+
raw: {
|
|
189
|
+
source: 'tokenStore',
|
|
190
|
+
expiresAt: tokenData.expiresAt,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
this.log.debug(
|
|
195
|
+
'CyncClient: access token applied from %s; userId=%s, expiresAt=%s',
|
|
196
|
+
'tokenStore',
|
|
197
|
+
tokenData.userId,
|
|
198
|
+
tokenData.expiresAt ? new Date(tokenData.expiresAt).toISOString() : 'unknown',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Step 1 of 2FA flow:
|
|
205
|
+
*
|
|
206
|
+
* Trigger an email with a one-time code to the Cync account email.
|
|
207
|
+
*
|
|
208
|
+
* Call sequence:
|
|
209
|
+
* await client.authenticate(username, password); // sends email
|
|
210
|
+
* // user reads email, gets code…
|
|
211
|
+
* await client.submitTwoFactor(username, password, code); // completes login
|
|
212
|
+
*/
|
|
213
|
+
public async authenticate(username: string): Promise<void> {
|
|
214
|
+
const email = username.trim();
|
|
215
|
+
|
|
216
|
+
this.log.info('CyncClient: requesting 2FA code for %s', email);
|
|
217
|
+
await this.configClient.sendTwoFactorCode(email);
|
|
218
|
+
this.log.info(
|
|
219
|
+
'CyncClient: 2FA email requested; call submitTwoFactor() once the user has the code.',
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Step 2 of 2FA flow:
|
|
225
|
+
*
|
|
226
|
+
* Use the emailed OTP code to complete login.
|
|
227
|
+
* This method is stateless: it does not rely on prior calls to authenticate()
|
|
228
|
+
* in the same process, so it works across Homebridge restarts.
|
|
229
|
+
*/
|
|
230
|
+
public async submitTwoFactor(
|
|
231
|
+
email: string,
|
|
232
|
+
password: string,
|
|
233
|
+
code: string,
|
|
234
|
+
): Promise<CyncLoginSession> {
|
|
235
|
+
const trimmedEmail = email.trim();
|
|
236
|
+
const trimmedCode = code.trim();
|
|
237
|
+
|
|
238
|
+
this.log.info('CyncClient: completing 2FA login for %s', trimmedEmail);
|
|
239
|
+
|
|
240
|
+
const session = await this.configClient.loginWithTwoFactor(
|
|
241
|
+
trimmedEmail,
|
|
242
|
+
password,
|
|
243
|
+
trimmedCode,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
this.session = session;
|
|
247
|
+
|
|
248
|
+
this.log.debug(
|
|
249
|
+
'CyncClient: session snapshot after login; hasAccessToken=%s userId=%s',
|
|
250
|
+
!!session.accessToken,
|
|
251
|
+
session.userId,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
this.log.info(
|
|
255
|
+
'CyncClient: login successful; userId=%s',
|
|
256
|
+
session.userId,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return session;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Fetch and cache the cloud configuration (meshes/devices) for the logged-in user.
|
|
265
|
+
*/
|
|
266
|
+
public async loadConfiguration(): Promise<CyncCloudConfig> {
|
|
267
|
+
this.ensureSession();
|
|
268
|
+
|
|
269
|
+
this.log.info('CyncClient: loading Cync cloud configuration…');
|
|
270
|
+
const cfg = await this.configClient.getCloudConfig();
|
|
271
|
+
|
|
272
|
+
// Debug: inspect per-mesh properties so we can find the real devices.
|
|
273
|
+
for (const mesh of cfg.meshes) {
|
|
274
|
+
const meshName = mesh.name ?? mesh.id;
|
|
275
|
+
this.log.debug(
|
|
276
|
+
'CyncClient: probing properties for mesh %s (id=%s, product_id=%s)',
|
|
277
|
+
meshName,
|
|
278
|
+
mesh.id,
|
|
279
|
+
mesh.product_id,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
const props = await this.configClient.getDeviceProperties(
|
|
284
|
+
mesh.product_id,
|
|
285
|
+
mesh.id,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
this.log.debug(
|
|
289
|
+
'CyncClient: mesh %s properties keys=%o',
|
|
290
|
+
meshName,
|
|
291
|
+
Object.keys(props),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
type DeviceProps = Record<string, unknown>;
|
|
295
|
+
const bulbsArray = (props as DeviceProps).bulbsArray as unknown;
|
|
296
|
+
if (Array.isArray(bulbsArray)) {
|
|
297
|
+
this.log.info(
|
|
298
|
+
'CyncClient: mesh %s bulbsArray length=%d; first item keys=%o',
|
|
299
|
+
meshName,
|
|
300
|
+
bulbsArray.length,
|
|
301
|
+
bulbsArray[0] ? Object.keys(bulbsArray[0] as Record<string, unknown>) : [],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
type RawDevice = Record<string, unknown>;
|
|
305
|
+
|
|
306
|
+
const rawDevices = bulbsArray as unknown[];
|
|
307
|
+
|
|
308
|
+
(mesh as Record<string, unknown>).devices = rawDevices.map((raw: unknown) => {
|
|
309
|
+
const d = raw as RawDevice;
|
|
310
|
+
|
|
311
|
+
const displayName = d.displayName as string | undefined;
|
|
312
|
+
const deviceID = (d.deviceID ?? d.deviceId) as string | undefined;
|
|
313
|
+
const wifiMac = d.wifiMac as string | undefined;
|
|
314
|
+
const productId = (d.product_id as string | undefined) ?? mesh.product_id;
|
|
315
|
+
|
|
316
|
+
// Use deviceID first, then wifiMac (stripped), then a mesh-based fallback.
|
|
317
|
+
const id =
|
|
318
|
+
deviceID ??
|
|
319
|
+
(wifiMac ? wifiMac.replace(/:/g, '') : undefined) ??
|
|
320
|
+
`${mesh.id}-${productId ?? 'unknown'}`;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
id,
|
|
324
|
+
name: displayName ?? undefined,
|
|
325
|
+
product_id: productId,
|
|
326
|
+
device_id: deviceID,
|
|
327
|
+
mac: wifiMac,
|
|
328
|
+
raw: d,
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
} else {
|
|
332
|
+
this.log.info(
|
|
333
|
+
'CyncClient: mesh %s has no bulbsArray in properties; props keys=%o',
|
|
334
|
+
meshName,
|
|
335
|
+
Object.keys(props),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
this.log.warn(
|
|
340
|
+
'CyncClient: getDeviceProperties failed for mesh %s (%s): %s',
|
|
341
|
+
meshName,
|
|
342
|
+
mesh.id,
|
|
343
|
+
(err as Error).message ?? String(err),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
this.cloudConfig = cfg;
|
|
349
|
+
this.log.info(
|
|
350
|
+
'CyncClient: cloud configuration loaded; meshes=%d',
|
|
351
|
+
cfg.meshes.length,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
return cfg;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Start the LAN/TCP transport (stub for now).
|
|
359
|
+
*/
|
|
360
|
+
public async startTransport(
|
|
361
|
+
config: CyncCloudConfig,
|
|
362
|
+
loginCode: Uint8Array,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
this.ensureSession();
|
|
365
|
+
this.log.info('CyncClient: starting TCP transport (stub)…');
|
|
366
|
+
|
|
367
|
+
await this.tcpClient.connect(loginCode, config);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
public async stopTransport(): Promise<void> {
|
|
371
|
+
this.log.info('CyncClient: stopping TCP transport…');
|
|
372
|
+
await this.tcpClient.disconnect();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* High-level helper for toggling a switch/plug.
|
|
377
|
+
*/
|
|
378
|
+
public async setSwitchState(
|
|
379
|
+
deviceId: string,
|
|
380
|
+
params: { on: boolean; [key: string]: unknown },
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
this.ensureSession();
|
|
383
|
+
|
|
384
|
+
this.log.debug(
|
|
385
|
+
'CyncClient: setSwitchState stub; deviceId=%s params=%o',
|
|
386
|
+
deviceId,
|
|
387
|
+
params,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
await this.tcpClient.setSwitchState(deviceId, params);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
public getSessionSnapshot(): CyncLoginSession | null {
|
|
394
|
+
return this.session;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
public getCloudConfigSnapshot(): CyncCloudConfig | null {
|
|
398
|
+
return this.cloudConfig;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private ensureSession(): void {
|
|
402
|
+
if (!this.session) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
'Cync session not initialised; complete 2FA login first.',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/cync/tcp-client.ts
|
|
2
|
+
// Thin TCP client stub for talking to Cync WiFi devices.
|
|
3
|
+
// The binary protocol is non-trivial; for now this class only logs calls so
|
|
4
|
+
// that higher layers can be wired up and tested without crashing.
|
|
5
|
+
|
|
6
|
+
import { CyncCloudConfig, CyncLogger } from './config-client.js';
|
|
7
|
+
|
|
8
|
+
const defaultLogger: CyncLogger = {
|
|
9
|
+
debug: (...args: unknown[]) => console.debug('[cync-tcp]', ...args),
|
|
10
|
+
info: (...args: unknown[]) => console.info('[cync-tcp]', ...args),
|
|
11
|
+
warn: (...args: unknown[]) => console.warn('[cync-tcp]', ...args),
|
|
12
|
+
error: (...args: unknown[]) => console.error('[cync-tcp]', ...args),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type DeviceUpdateCallback = (payload: unknown) => void;
|
|
16
|
+
|
|
17
|
+
export class TcpClient {
|
|
18
|
+
private readonly log: CyncLogger;
|
|
19
|
+
|
|
20
|
+
private deviceUpdateCb: DeviceUpdateCallback | null = null;
|
|
21
|
+
private roomUpdateCb: DeviceUpdateCallback | null = null;
|
|
22
|
+
private motionUpdateCb: DeviceUpdateCallback | null = null;
|
|
23
|
+
private ambientUpdateCb: DeviceUpdateCallback | null = null;
|
|
24
|
+
|
|
25
|
+
constructor(logger?: CyncLogger) {
|
|
26
|
+
this.log = logger ?? defaultLogger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Establish a TCP session to one or more Cync devices.
|
|
31
|
+
*
|
|
32
|
+
* In the full implementation, loginCode will be the authentication blob used
|
|
33
|
+
* by the LAN devices, and config will contain the mesh/network information
|
|
34
|
+
* needed to discover and connect to the correct hosts.
|
|
35
|
+
*
|
|
36
|
+
* For now this is a no-op that simply logs the request.
|
|
37
|
+
*/
|
|
38
|
+
public async connect(
|
|
39
|
+
loginCode: Uint8Array,
|
|
40
|
+
config: CyncCloudConfig,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
this.log.info(
|
|
43
|
+
'TcpClient.connect() stub called with loginCode length=%d meshes=%d',
|
|
44
|
+
loginCode.length,
|
|
45
|
+
config.meshes.length,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public async disconnect(): Promise<void> {
|
|
50
|
+
this.log.info('TcpClient.disconnect() stub called.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public onDeviceUpdate(cb: DeviceUpdateCallback): void {
|
|
54
|
+
this.deviceUpdateCb = cb;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public onRoomUpdate(cb: DeviceUpdateCallback): void {
|
|
58
|
+
this.roomUpdateCb = cb;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public onMotionUpdate(cb: DeviceUpdateCallback): void {
|
|
62
|
+
this.motionUpdateCb = cb;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public onAmbientUpdate(cb: DeviceUpdateCallback): void {
|
|
66
|
+
this.ambientUpdateCb = cb;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* High-level API to change switch state. The actual encoding and TCP send
|
|
71
|
+
* will be filled in once the LAN protocol is implemented.
|
|
72
|
+
*/
|
|
73
|
+
public async setSwitchState(
|
|
74
|
+
deviceId: string,
|
|
75
|
+
params: { on: boolean; [key: string]: unknown },
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
this.log.info(
|
|
78
|
+
'TcpClient.setSwitchState() stub: deviceId=%s params=%o',
|
|
79
|
+
deviceId,
|
|
80
|
+
params,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// In a future implementation, this is where we would:
|
|
84
|
+
// 1. Look up the device in the current CyncCloudConfig.
|
|
85
|
+
// 2. Construct the appropriate binary payload.
|
|
86
|
+
// 3. Send via a net.Socket and handle the response.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/cync/token-store.ts
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface CyncTokenData {
|
|
6
|
+
userId: string;
|
|
7
|
+
accessToken: string;
|
|
8
|
+
refreshToken?: string;
|
|
9
|
+
expiresAt?: number; // epoch ms, optional if Cync doesn't provide expiry
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Simple JSON token store under the Homebridge storage path.
|
|
14
|
+
*/
|
|
15
|
+
export class CyncTokenStore {
|
|
16
|
+
private readonly filePath: string;
|
|
17
|
+
|
|
18
|
+
public constructor(storagePath: string) {
|
|
19
|
+
this.filePath = path.join(storagePath, 'cync-tokens.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public async load(): Promise<CyncTokenData | null> {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(this.filePath, 'utf8');
|
|
25
|
+
const data = JSON.parse(raw) as CyncTokenData;
|
|
26
|
+
|
|
27
|
+
// If expiresAt is set and in the past, treat as invalid
|
|
28
|
+
if (data.expiresAt && data.expiresAt <= Date.now()) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return data;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async save(data: CyncTokenData): Promise<void> {
|
|
39
|
+
const json = JSON.stringify(data, null, 2);
|
|
40
|
+
await fs.writeFile(this.filePath, json, 'utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public async clear(): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
await fs.unlink(this.filePath);
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore if missing
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
File without changes
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { API } from 'homebridge';
|
|
2
|
+
|
|
3
|
+
import { CyncAppPlatform } from './platform.js';
|
|
4
|
+
import { PLATFORM_NAME } from './settings.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Homebridge entry point.
|
|
8
|
+
* Registers the CyncAppPlatform with Homebridge under PLATFORM_NAME.
|
|
9
|
+
*/
|
|
10
|
+
export default (api: API) => {
|
|
11
|
+
api.registerPlatform(PLATFORM_NAME, CyncAppPlatform);
|
|
12
|
+
};
|