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.
Files changed (50) hide show
  1. package/.editorconfig +10 -0
  2. package/CHANGELOG.md +33 -0
  3. package/LICENSE +176 -0
  4. package/README.md +67 -0
  5. package/config.schema.json +39 -0
  6. package/dist/cync/config-client.d.ts +77 -0
  7. package/dist/cync/config-client.js +222 -0
  8. package/dist/cync/config-client.js.map +1 -0
  9. package/dist/cync/cync-client.d.ts +76 -0
  10. package/dist/cync/cync-client.js +236 -0
  11. package/dist/cync/cync-client.js.map +1 -0
  12. package/dist/cync/tcp-client.d.ts +33 -0
  13. package/dist/cync/tcp-client.js +59 -0
  14. package/dist/cync/tcp-client.js.map +1 -0
  15. package/dist/cync/token-store.d.ts +16 -0
  16. package/dist/cync/token-store.js +39 -0
  17. package/dist/cync/token-store.js.map +1 -0
  18. package/dist/cync/types.d.ts +1 -0
  19. package/dist/cync/types.js +2 -0
  20. package/dist/cync/types.js.map +1 -0
  21. package/dist/index.d.ts +7 -0
  22. package/dist/index.js +10 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/platform.d.ts +29 -0
  25. package/dist/platform.js +143 -0
  26. package/dist/platform.js.map +1 -0
  27. package/dist/platformAccessory.d.ts +13 -0
  28. package/dist/platformAccessory.js +17 -0
  29. package/dist/platformAccessory.js.map +1 -0
  30. package/dist/settings.d.ts +2 -0
  31. package/dist/settings.js +3 -0
  32. package/dist/settings.js.map +1 -0
  33. package/docs/cync-api-notes.md +168 -0
  34. package/docs/cync-client-contract.md +172 -0
  35. package/docs/cync-device-model.md +129 -0
  36. package/eslint.config.js +41 -0
  37. package/homebridge-cync-app-v0.0.1.zip +0 -0
  38. package/nodemon.json +12 -0
  39. package/package.json +56 -0
  40. package/src/@types/homebridge-lib.d.ts +14 -0
  41. package/src/cync/config-client.ts +370 -0
  42. package/src/cync/cync-client.ts +408 -0
  43. package/src/cync/tcp-client.ts +88 -0
  44. package/src/cync/token-store.ts +50 -0
  45. package/src/cync/types.ts +0 -0
  46. package/src/index.ts +12 -0
  47. package/src/platform.ts +209 -0
  48. package/src/platformAccessory.ts +18 -0
  49. package/src/settings.ts +3 -0
  50. package/tsconfig.json +24 -0
@@ -0,0 +1,41 @@
1
+ import eslint from '@eslint/js';
2
+ import tseslint from 'typescript-eslint';
3
+
4
+ export default tseslint.config(
5
+ {
6
+ ignores: ['dist/**'],
7
+ },
8
+ {
9
+ rules: {
10
+ // enforce tabs
11
+ indent: ['error', 'tab'],
12
+ 'no-tabs': 'off',
13
+
14
+ // formatting
15
+ quotes: ['error', 'single'],
16
+ 'linebreak-style': ['error', 'unix'],
17
+ semi: ['error', 'always'],
18
+ 'comma-dangle': ['error', 'always-multiline'],
19
+ 'dot-notation': 'error',
20
+ eqeqeq: ['error', 'smart'],
21
+ curly: ['error', 'all'],
22
+ 'brace-style': ['error'],
23
+ 'prefer-arrow-callback': 'warn',
24
+ 'max-len': ['warn', 160],
25
+ 'object-curly-spacing': ['error', 'always'],
26
+
27
+ // TypeScript-specific rules
28
+ 'no-use-before-define': 'off',
29
+ '@typescript-eslint/no-use-before-define': ['error', { classes: false, enums: false }],
30
+ '@typescript-eslint/no-unused-vars': ['error', { caughtErrors: 'none' }],
31
+ },
32
+ },
33
+ {
34
+ languageOptions: {
35
+ ecmaVersion: 2022,
36
+ sourceType: 'module',
37
+ },
38
+ },
39
+ eslint.configs.recommended,
40
+ ...tseslint.configs.recommended,
41
+ );
Binary file
package/nodemon.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "watch": [
3
+ "src"
4
+ ],
5
+ "ext": "ts",
6
+ "ignore": [],
7
+ "exec": "tsc && homebridge -U ./test/hbConfig -D",
8
+ "signal": "SIGTERM",
9
+ "env": {
10
+ "NODE_OPTIONS": "--trace-warnings"
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "homebridge-cync-app",
3
+ "displayName": "Homebridge Cync App",
4
+ "type": "module",
5
+ "version": "0.0.2",
6
+ "private": false,
7
+ "description": "Homebridge plugin that integrates your GE Cync account (via the Cync app/API) and exposes all supported devices: plugs, lights, switches, etc",
8
+ "author": "Dustin Newell",
9
+ "license": "Apache-2.0",
10
+ "homepage": "https://github.com/dash16/homebridge-cync-app#readme",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/dash16/homebridge-cync-app.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/dash16/homebridge-cync-app/issues"
17
+ },
18
+ "keywords": [
19
+ "homebridge-plugin",
20
+ "homebridge",
21
+ "cync",
22
+ "ge cync",
23
+ "smart plug",
24
+ "smart lights",
25
+ "C by GE"
26
+ ],
27
+ "main": "dist/index.js",
28
+ "homebridge": {
29
+ "pluginType": "platform",
30
+ "platform": "CyncAppPlatform"
31
+ },
32
+ "engines": {
33
+ "node": "^20.18.0 || ^22.10.0 || ^24.0.0",
34
+ "homebridge": "^1.8.0 || ^2.0.0-beta.0"
35
+ },
36
+ "scripts": {
37
+ "build": "rimraf ./dist && tsc",
38
+ "lint": "eslint . --max-warnings=0",
39
+ "prepublishOnly": "npm run lint && npm run build",
40
+ "watch": "npm run build && npm link && nodemon"
41
+ },
42
+ "dependencies": {
43
+ "homebridge-lib": "^7.1.12"
44
+ },
45
+ "devDependencies": {
46
+ "@eslint/js": "^9.39.1",
47
+ "@types/node": "^24.10.1",
48
+ "eslint": "^9.39.1",
49
+ "homebridge": "^2.0.0-beta.55",
50
+ "nodemon": "^3.1.11",
51
+ "rimraf": "^6.1.0",
52
+ "ts-node": "^10.9.2",
53
+ "typescript": "^5.9.3",
54
+ "typescript-eslint": "^8.46.4"
55
+ }
56
+ }
@@ -0,0 +1,14 @@
1
+ declare module 'homebridge-lib/EveHomeKitTypes' {
2
+ export class EveHomeKitTypes {
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ constructor(homebridge: any);
5
+
6
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
+ Characteristics: Record<string, any>;
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ Services: Record<string, any>;
10
+ }
11
+ }
12
+
13
+ declare module 'homebridge-lib' {
14
+ }
@@ -0,0 +1,370 @@
1
+ // src/cync/config-client.ts
2
+ // Cync cloud configuration & login client.
3
+ // Handles 2FA email flow and basic device/config queries against api.gelighting.com.
4
+ //
5
+ // This is intentionally low-level and stateless-ish: CyncClient is expected to
6
+ // own an instance of this class and persist the resulting session info.
7
+
8
+ const CYNC_API_BASE = 'https://api.gelighting.com/v2/';
9
+ const CORP_ID = '1007d2ad150c4000';
10
+
11
+ // Minimal fetch/response typing for Node 18+, without depending on DOM lib types.
12
+ type FetchLike = (input: unknown, init?: unknown) => Promise<unknown>;
13
+
14
+ declare const fetch: FetchLike;
15
+
16
+ type HttpResponse = {
17
+ ok: boolean;
18
+ status: number;
19
+ statusText: string;
20
+ json(): Promise<unknown>;
21
+ text(): Promise<string>;
22
+ };
23
+
24
+ type CyncErrorBody = {
25
+ error?: {
26
+ msg?: string;
27
+ [key: string]: unknown;
28
+ };
29
+ [key: string]: unknown;
30
+ };
31
+
32
+ export interface CyncLoginSession {
33
+ accessToken: string;
34
+ userId: string;
35
+ raw: unknown;
36
+ }
37
+
38
+ export interface CyncDevice {
39
+ id: string;
40
+ name?: string;
41
+ product_id?: string;
42
+ device_id?: string;
43
+ mac?: string;
44
+ sn?: string;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ export interface CyncDeviceMesh {
49
+ id: string;
50
+ name?: string;
51
+ product_id: string;
52
+ access_key?: string;
53
+ mac?: string;
54
+ properties?: Record<string, unknown>;
55
+ devices?: CyncDevice[];
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ export interface CyncCloudConfig {
60
+ meshes: CyncDeviceMesh[];
61
+ }
62
+
63
+ /**
64
+ * Very small logger interface so we can accept either the Homebridge log
65
+ * object or console.* functions in tests.
66
+ */
67
+ export interface CyncLogger {
68
+ debug(message: string, ...args: unknown[]): void;
69
+ info(message: string, ...args: unknown[]): void;
70
+ warn(message: string, ...args: unknown[]): void;
71
+ error(message: string, ...args: unknown[]): void;
72
+ }
73
+
74
+ const defaultLogger: CyncLogger = {
75
+ debug: (...args: unknown[]) => console.debug('[cync-config]', ...args),
76
+ info: (...args: unknown[]) => console.info('[cync-config]', ...args),
77
+ warn: (...args: unknown[]) => console.warn('[cync-config]', ...args),
78
+ error: (...args: unknown[]) => console.error('[cync-config]', ...args),
79
+ };
80
+
81
+ export class ConfigClient {
82
+ private readonly log: CyncLogger;
83
+
84
+ // These are populated after a successful 2FA login.
85
+ private accessToken: string | null = null;
86
+ private userId: string | null = null;
87
+
88
+ constructor(logger?: CyncLogger) {
89
+ this.log = logger ?? defaultLogger;
90
+ }
91
+
92
+ /**
93
+ * Request that Cync send a one-time 2FA verification code to the given email.
94
+ *
95
+ * This MUST be called before loginWithTwoFactor() for accounts that require 2FA.
96
+ * The user reads the code from their email and provides it to loginWithTwoFactor.
97
+ */
98
+ public async sendTwoFactorCode(email: string): Promise<void> {
99
+ const url = `${CYNC_API_BASE}two_factor/email/verifycode`;
100
+ this.log.debug(`Requesting Cync 2FA code for ${email}…`);
101
+
102
+ const body = {
103
+ corp_id: CORP_ID,
104
+ email,
105
+ local_lang: 'en-us',
106
+ };
107
+
108
+ const res = (await fetch(url, {
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/json',
112
+ },
113
+ body: JSON.stringify(body),
114
+ })) as HttpResponse;
115
+
116
+ if (!res.ok) {
117
+ const text = await res.text().catch(() => '');
118
+ this.log.error(
119
+ `Cync 2FA request failed: HTTP ${res.status} ${res.statusText} ${text}`,
120
+ );
121
+ throw new Error(`Cync 2FA request failed with status ${res.status}`);
122
+ }
123
+
124
+ this.log.info('Cync 2FA email request succeeded.');
125
+ }
126
+
127
+ /**
128
+ * Perform the actual 2FA login and capture the access token + userId.
129
+ *
130
+ * You are expected to first call sendTwoFactorCode(), then prompt the user
131
+ * for the emailed OTP code, then call loginWithTwoFactor() with that code.
132
+ *
133
+ * The access token is used for subsequent getCloudConfig() / getDeviceProperties() calls.
134
+ */
135
+ public async loginWithTwoFactor(
136
+ email: string,
137
+ password: string,
138
+ otpCode: string,
139
+ ): Promise<CyncLoginSession> {
140
+ const url = `${CYNC_API_BASE}user_auth/two_factor`;
141
+ this.log.debug('Logging into Cync with 2FA for %s…', email);
142
+
143
+ const body = {
144
+ corp_id: CORP_ID,
145
+ email,
146
+ password,
147
+ two_factor: otpCode,
148
+ // Matches the reference implementations: random 16-char string.
149
+ resource: ConfigClient.randomLoginResource(),
150
+ };
151
+
152
+ const res = (await fetch(url, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json',
156
+ },
157
+ body: JSON.stringify(body),
158
+ })) as HttpResponse;
159
+
160
+ const json: unknown = await res.json().catch(async () => {
161
+ const text = await res.text().catch(() => '');
162
+ throw new Error(`Cync login returned non-JSON payload: ${text}`);
163
+ });
164
+
165
+ if (!res.ok) {
166
+ this.log.error(
167
+ 'Cync login failed: HTTP %d %s %o',
168
+ res.status,
169
+ res.statusText,
170
+ json,
171
+ );
172
+ const errBody = json as CyncErrorBody;
173
+ throw new Error(
174
+ errBody.error?.msg ??
175
+ `Cync login failed with status ${res.status} ${res.statusText}`,
176
+ );
177
+ }
178
+
179
+ const obj = json as Record<string, unknown>;
180
+ this.log.debug('Cync login response: keys=%o', Object.keys(obj));
181
+
182
+ // Accept both snake_case and camelCase, and both string/number user_id.
183
+ const accessTokenRaw = obj.access_token ?? obj.accessToken;
184
+ const userIdRaw = obj.user_id ?? obj.userId;
185
+
186
+ const accessToken =
187
+ typeof accessTokenRaw === 'string' && accessTokenRaw.length > 0
188
+ ? accessTokenRaw
189
+ : undefined;
190
+
191
+ const userId =
192
+ userIdRaw !== undefined && userIdRaw !== null
193
+ ? String(userIdRaw)
194
+ : undefined;
195
+
196
+ if (!accessToken || !userId) {
197
+ this.log.error('Cync login missing access_token or user_id: %o', json);
198
+ throw new Error('Cync login response missing access_token or user_id');
199
+ }
200
+
201
+ this.accessToken = accessToken;
202
+ this.userId = userId;
203
+
204
+ this.log.info('Cync login successful; userId=%s', userId);
205
+
206
+ return {
207
+ accessToken,
208
+ userId,
209
+ raw: json,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Fetch the list of meshes/devices for the current user from the cloud.
215
+ *
216
+ * This roughly matches CyncCloudAPI.get_devices() from cync-lan, but in a
217
+ * simplified, single-call interface.
218
+ */
219
+ public async getCloudConfig(): Promise<CyncCloudConfig> {
220
+ this.ensureSession();
221
+
222
+ const devicesUrl = `${CYNC_API_BASE}user/${this.userId}/subscribe/devices`;
223
+ const headers = {
224
+ 'Access-Token': this.accessToken as string,
225
+ };
226
+
227
+ this.log.debug('Fetching Cync devices from %s', devicesUrl);
228
+
229
+ const res = (await fetch(devicesUrl, {
230
+ method: 'GET',
231
+ headers,
232
+ })) as HttpResponse;
233
+
234
+ const json: unknown = await res.json().catch(async () => {
235
+ const text = await res.text().catch(() => '');
236
+ throw new Error(`Cync devices returned non-JSON payload: ${text}`);
237
+ });
238
+
239
+ if (!res.ok) {
240
+ this.log.error(
241
+ 'Cync devices call failed: HTTP %d %s %o',
242
+ res.status,
243
+ res.statusText,
244
+ json,
245
+ );
246
+ const errBody = json as CyncErrorBody;
247
+ const msg = errBody.error?.msg ?? 'Unknown error from Cync devices API';
248
+ throw new Error(msg);
249
+ }
250
+
251
+ // DEBUG: log high-level shape without dumping any secrets.
252
+ if (Array.isArray(json)) {
253
+ this.log.debug(
254
+ 'Cync devices payload: top-level array length=%d; first item keys=%o',
255
+ json.length,
256
+ json.length > 0 ? Object.keys((json as Record<string, unknown>[])[0]) : [],
257
+ );
258
+ } else if (json && typeof json === 'object') {
259
+ this.log.debug(
260
+ 'Cync devices payload: top-level object keys=%o',
261
+ Object.keys(json as Record<string, unknown>),
262
+ );
263
+ } else {
264
+ this.log.debug(
265
+ 'Cync devices payload: top-level type=%s',
266
+ typeof json,
267
+ );
268
+ }
269
+
270
+ // Some Cync responses wrap arrays; others are raw arrays.
271
+ let meshes: CyncDeviceMesh[] = [];
272
+
273
+ if (Array.isArray(json)) {
274
+ meshes = json as CyncDeviceMesh[];
275
+ } else if (json && typeof json === 'object') {
276
+ const obj = json as Record<string, unknown>;
277
+
278
+ // Best guess: devices may be under a named property.
279
+ // We just log for now; once we see the payload, we can wire this properly.
280
+ this.log.debug(
281
+ 'Cync devices payload (object) example values for known keys=%o',
282
+ {
283
+ dataType: typeof obj.data,
284
+ devicesType: typeof (obj.devices as unknown),
285
+ meshesType: typeof (obj.meshes as unknown),
286
+ },
287
+ );
288
+
289
+ // Temporary: if there's a "data" array, treat that as meshes.
290
+ if (Array.isArray(obj.data)) {
291
+ meshes = obj.data as CyncDeviceMesh[];
292
+ } else if (Array.isArray(obj.meshes)) {
293
+ meshes = obj.meshes as CyncDeviceMesh[];
294
+ }
295
+ }
296
+
297
+ return { meshes };
298
+
299
+ }
300
+
301
+ /**
302
+ * Convenience to fetch the properties object for a single device.
303
+ */
304
+ public async getDeviceProperties(
305
+ productId: string,
306
+ deviceId: string,
307
+ ): Promise<Record<string, unknown>> {
308
+ this.ensureSession();
309
+
310
+ const url = `${CYNC_API_BASE}product/${encodeURIComponent(
311
+ productId,
312
+ )}/device/${encodeURIComponent(deviceId)}/property`;
313
+
314
+ const res = (await fetch(url, {
315
+ method: 'GET',
316
+ headers: {
317
+ 'Access-Token': this.accessToken as string,
318
+ },
319
+ })) as HttpResponse;
320
+
321
+ const json: unknown = await res.json().catch(async () => {
322
+ const text = await res.text().catch(() => '');
323
+ throw new Error(`Cync properties returned non-JSON payload: ${text}`);
324
+ });
325
+
326
+ if (!res.ok) {
327
+ this.log.error(
328
+ 'Cync properties call failed: HTTP %d %s %o',
329
+ res.status,
330
+ res.statusText,
331
+ json,
332
+ );
333
+ const errBody = json as CyncErrorBody;
334
+ const msg =
335
+ errBody.error?.msg ?? `Cync properties failed with ${res.status}`;
336
+ throw new Error(msg);
337
+ }
338
+
339
+ // We keep this as a loose record; callers can shape it as needed.
340
+ return json as Record<string, unknown>;
341
+ }
342
+
343
+ public restoreSession(accessToken: string, userId: string): void {
344
+ this.accessToken = accessToken;
345
+ this.userId = userId;
346
+ this.log.info('Cync: restored session from stored token; userId=%s', userId);
347
+ }
348
+
349
+ public getSessionSnapshot(): { accessToken: string | null; userId: string | null } {
350
+ return {
351
+ accessToken: this.accessToken,
352
+ userId: this.userId,
353
+ };
354
+ }
355
+
356
+ private ensureSession(): void {
357
+ if (!this.accessToken || !this.userId) {
358
+ throw new Error('Cync session not initialised. Call loginWithTwoFactor() first.');
359
+ }
360
+ }
361
+
362
+ private static randomLoginResource(): string {
363
+ const chars = 'abcdefghijklmnopqrstuvwxyz';
364
+ let out = '';
365
+ for (let i = 0; i < 16; i += 1) {
366
+ out += chars.charAt(Math.floor(Math.random() * chars.length));
367
+ }
368
+ return out;
369
+ }
370
+ }