homebridge-cync-app 0.1.3 → 0.1.5
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/CHANGELOG.md +23 -11
- package/README.md +13 -20
- package/config.schema.json +2 -7
- package/dist/cync/config-client.d.ts +4 -0
- package/dist/cync/config-client.js +1 -0
- package/dist/cync/config-client.js.map +1 -1
- package/dist/cync/cync-client.d.ts +8 -7
- package/dist/cync/cync-client.js +31 -19
- package/dist/cync/cync-client.js.map +1 -1
- package/dist/cync/tcp-client.d.ts +10 -8
- package/dist/cync/tcp-client.js +63 -80
- package/dist/cync/tcp-client.js.map +1 -1
- package/dist/cync/token-store.d.ts +0 -4
- package/dist/cync/token-store.js +2 -10
- package/dist/cync/token-store.js.map +1 -1
- package/dist/platform.d.ts +1 -4
- package/dist/platform.js +29 -18
- package/dist/platform.js.map +1 -1
- package/homebridge-ui/public/index.html +171 -110
- package/homebridge-ui/server.js +38 -73
- package/package.json +3 -9
- package/src/cync/config-client.ts +6 -1
- package/src/cync/cync-client.ts +44 -26
- package/src/cync/tcp-client.ts +78 -111
- package/src/cync/token-store.ts +2 -11
- package/src/platform.ts +39 -20
package/homebridge-ui/server.js
CHANGED
|
@@ -1,97 +1,62 @@
|
|
|
1
1
|
// homebridge-ui/server.js
|
|
2
2
|
import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils';
|
|
3
|
-
import
|
|
3
|
+
import { ConfigClient } from '../dist/cync/config-client.js';
|
|
4
|
+
import { CyncTokenStore } from '../dist/cync/token-store.js';
|
|
4
5
|
|
|
5
|
-
class
|
|
6
|
+
class CyncUiServer extends HomebridgePluginUiServer {
|
|
6
7
|
constructor() {
|
|
7
8
|
super();
|
|
8
9
|
|
|
9
|
-
this.
|
|
10
|
-
|
|
10
|
+
this.configClient = new ConfigClient({
|
|
11
|
+
debug: (...a) => console.debug('[cync-ui-config]', ...a),
|
|
12
|
+
info: (...a) => console.info('[cync-ui-config]', ...a),
|
|
13
|
+
warn: (...a) => console.warn('[cync-ui-config]', ...a),
|
|
14
|
+
error: (...a) => console.error('[cync-ui-config]', ...a),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
this.tokenStore = new CyncTokenStore(this.homebridgeStoragePath);
|
|
18
|
+
|
|
19
|
+
this.onRequest('/request-otp', this.handleRequestOtp.bind(this));
|
|
20
|
+
this.onRequest('/sign-out', this.handleSignOut.bind(this));
|
|
21
|
+
this.onRequest('/status', this.handleStatus.bind(this));
|
|
11
22
|
|
|
12
|
-
// Tell Homebridge UI that we’re ready
|
|
13
23
|
this.ready();
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const email = (payload?.emailAddress || '').trim();
|
|
26
|
+
async handleRequestOtp(payload) {
|
|
27
|
+
const email = typeof payload?.email === 'string' ? payload.email.trim() : '';
|
|
19
28
|
if (!email) {
|
|
20
|
-
|
|
29
|
+
return { ok: false, error: 'Missing email' };
|
|
21
30
|
}
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
email,
|
|
26
|
-
local_lang: 'en-us',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
await fetch(
|
|
30
|
-
'https://api.gelighting.com/v2/two_factor/email/verifycode',
|
|
31
|
-
{
|
|
32
|
-
method: 'POST',
|
|
33
|
-
body: JSON.stringify(requestBody),
|
|
34
|
-
headers: { 'Content-Type': 'application/json' },
|
|
35
|
-
},
|
|
36
|
-
);
|
|
32
|
+
await this.configClient.sendTwoFactorCode(email);
|
|
33
|
+
return { ok: true };
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
//
|
|
40
|
-
async
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!email || !password || !mfaCode) {
|
|
46
|
-
return {
|
|
47
|
-
error: 'Email, password, and 2FA code are required.',
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const requestBody = {
|
|
52
|
-
corp_id: '1007d2ad150c4000',
|
|
53
|
-
email,
|
|
54
|
-
password,
|
|
55
|
-
two_factor: mfaCode,
|
|
56
|
-
resource: 'abcdefghijk',
|
|
57
|
-
};
|
|
36
|
+
// Delete token file
|
|
37
|
+
async handleSignOut() {
|
|
38
|
+
await this.tokenStore.clear();
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
58
41
|
|
|
42
|
+
// Report whether a token exists
|
|
43
|
+
async handleStatus() {
|
|
59
44
|
try {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
method: 'POST',
|
|
64
|
-
body: JSON.stringify(requestBody),
|
|
65
|
-
headers: { 'Content-Type': 'application/json' },
|
|
66
|
-
},
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
const data = await response.json();
|
|
70
|
-
if (data && data.error) {
|
|
71
|
-
return {
|
|
72
|
-
error:
|
|
73
|
-
'Login failed. Please check your password and 2FA code.',
|
|
74
|
-
};
|
|
45
|
+
const token = await this.tokenStore.load();
|
|
46
|
+
if (!token) {
|
|
47
|
+
return { ok: true, hasToken: false };
|
|
75
48
|
}
|
|
76
|
-
} catch (err) {
|
|
77
|
-
console.error('[cync-ui] Login request failed:', err);
|
|
78
49
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
50
|
+
ok: true,
|
|
51
|
+
hasToken: true,
|
|
52
|
+
userId: token.userId,
|
|
53
|
+
expiresAt: token.expiresAt ?? null,
|
|
81
54
|
};
|
|
55
|
+
} catch {
|
|
56
|
+
// On error, just say "no token"
|
|
57
|
+
return { ok: true, hasToken: false };
|
|
82
58
|
}
|
|
83
|
-
|
|
84
|
-
// At this point, Cync accepted the credentials.
|
|
85
|
-
// Return the platform config that your platform.ts expects.
|
|
86
|
-
return {
|
|
87
|
-
platform: 'CyncAppPlatform',
|
|
88
|
-
name: 'Cync App',
|
|
89
|
-
username: email,
|
|
90
|
-
password,
|
|
91
|
-
twoFactor: mfaCode,
|
|
92
|
-
};
|
|
93
59
|
}
|
|
94
60
|
}
|
|
95
61
|
|
|
96
|
-
|
|
97
|
-
(() => new PluginUiServer())();
|
|
62
|
+
(() => new CyncUiServer())();
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "homebridge-cync-app",
|
|
3
3
|
"displayName": "Homebridge Cync App",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.5",
|
|
6
6
|
"private": false,
|
|
7
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
8
|
"author": "Dustin Newell",
|
|
@@ -19,17 +19,12 @@
|
|
|
19
19
|
"homebridge-plugin",
|
|
20
20
|
"homebridge",
|
|
21
21
|
"cync",
|
|
22
|
-
"ge",
|
|
23
22
|
"ge cync",
|
|
24
|
-
"ge-lighting",
|
|
25
23
|
"smart plug",
|
|
26
24
|
"smart lights",
|
|
27
25
|
"C by GE"
|
|
28
26
|
],
|
|
29
27
|
"main": "dist/index.js",
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
32
|
-
},
|
|
33
28
|
"homebridge": {
|
|
34
29
|
"pluginType": "platform",
|
|
35
30
|
"platform": "CyncAppPlatform"
|
|
@@ -45,9 +40,8 @@
|
|
|
45
40
|
"watch": "npm run build && npm link && nodemon"
|
|
46
41
|
},
|
|
47
42
|
"dependencies": {
|
|
48
|
-
"homebridge-
|
|
49
|
-
"
|
|
50
|
-
"@homebridge/plugin-ui-utils": "^1.0.0"
|
|
43
|
+
"@homebridge/plugin-ui-utils": "^2.1.2",
|
|
44
|
+
"homebridge-lib": "^7.1.12"
|
|
51
45
|
},
|
|
52
46
|
"devDependencies": {
|
|
53
47
|
"@eslint/js": "^9.39.1",
|
|
@@ -8,6 +8,7 @@
|
|
|
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.
|
|
11
12
|
type FetchLike = (input: unknown, init?: unknown) => Promise<unknown>;
|
|
12
13
|
|
|
13
14
|
declare const fetch: FetchLike;
|
|
@@ -64,7 +65,10 @@ export interface CyncCloudConfig {
|
|
|
64
65
|
meshes: CyncDeviceMesh[];
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Very small logger interface so we can accept either the Homebridge log
|
|
70
|
+
* object or console.* functions in tests.
|
|
71
|
+
*/
|
|
68
72
|
export interface CyncLogger {
|
|
69
73
|
debug(message: string, ...args: unknown[]): void;
|
|
70
74
|
info(message: string, ...args: unknown[]): void;
|
|
@@ -147,6 +151,7 @@ export class ConfigClient {
|
|
|
147
151
|
email,
|
|
148
152
|
password,
|
|
149
153
|
two_factor: otpCode,
|
|
154
|
+
// Matches the reference implementations: random 16-char string.
|
|
150
155
|
resource: ConfigClient.randomLoginResource(),
|
|
151
156
|
};
|
|
152
157
|
|
package/src/cync/cync-client.ts
CHANGED
|
@@ -42,7 +42,12 @@ export class CyncClient {
|
|
|
42
42
|
private switchIdToHomeId: Record<number, string> = {};
|
|
43
43
|
|
|
44
44
|
// Credentials from config.json, used to drive 2FA bootstrap.
|
|
45
|
-
|
|
45
|
+
//
|
|
46
|
+
// Canonical keys (must match platform + config):
|
|
47
|
+
// - username: login identifier (email address used in Cync app)
|
|
48
|
+
// - password: account password
|
|
49
|
+
// - twoFactor: 6-digit OTP, optional; when present we complete 2FA on restart.
|
|
50
|
+
private readonly loginConfig: { username: string; password: string; twoFactor?: string };
|
|
46
51
|
|
|
47
52
|
// Optional LAN update hook for the platform
|
|
48
53
|
private lanUpdateHandler: ((update: unknown) => void) | null = null;
|
|
@@ -70,7 +75,7 @@ export class CyncClient {
|
|
|
70
75
|
constructor(
|
|
71
76
|
configClient: ConfigClient,
|
|
72
77
|
tcpClient: TcpClient,
|
|
73
|
-
loginConfig: {
|
|
78
|
+
loginConfig: { username: string; password: string; twoFactor?: string },
|
|
74
79
|
storagePath: string,
|
|
75
80
|
logger?: CyncLogger,
|
|
76
81
|
) {
|
|
@@ -80,7 +85,18 @@ export class CyncClient {
|
|
|
80
85
|
|
|
81
86
|
this.loginConfig = loginConfig;
|
|
82
87
|
this.tokenStore = new CyncTokenStore(storagePath);
|
|
88
|
+
|
|
89
|
+
// One-time sanity log so we can see exactly what was passed in from platform/config.
|
|
90
|
+
this.log.debug(
|
|
91
|
+
'CyncClient: constructed with loginConfig=%o',
|
|
92
|
+
{
|
|
93
|
+
username: loginConfig.username,
|
|
94
|
+
hasPassword: !!loginConfig.password,
|
|
95
|
+
twoFactor: loginConfig.twoFactor,
|
|
96
|
+
},
|
|
97
|
+
);
|
|
83
98
|
}
|
|
99
|
+
|
|
84
100
|
// ### 🧩 LAN Login Code Builder
|
|
85
101
|
private buildLanLoginCode(authorize: string, userId: number): Uint8Array {
|
|
86
102
|
const authorizeBytes = Buffer.from(authorize, 'ascii');
|
|
@@ -133,17 +149,20 @@ export class CyncClient {
|
|
|
133
149
|
}
|
|
134
150
|
|
|
135
151
|
// 2) No stored token – run 2FA bootstrap
|
|
136
|
-
const {
|
|
152
|
+
const { username, password, twoFactor } = this.loginConfig;
|
|
137
153
|
|
|
138
|
-
if (!
|
|
139
|
-
this.log.error('CyncClient:
|
|
154
|
+
if (!username || !password) {
|
|
155
|
+
this.log.error('CyncClient: username and password are required to obtain a new token.');
|
|
140
156
|
return false;
|
|
141
157
|
}
|
|
142
158
|
|
|
143
|
-
|
|
159
|
+
const trimmedCode = typeof twoFactor === 'string' ? twoFactor.trim() : '';
|
|
160
|
+
const hasTwoFactor = trimmedCode.length > 0;
|
|
161
|
+
|
|
162
|
+
if (!hasTwoFactor) {
|
|
144
163
|
// No 2FA code – request one
|
|
145
|
-
this.log.info('Cync: starting 2FA handshake for %s',
|
|
146
|
-
await this.requestTwoFactorCode(
|
|
164
|
+
this.log.info('Cync: starting 2FA handshake for %s', username);
|
|
165
|
+
await this.requestTwoFactorCode(username);
|
|
147
166
|
this.log.info(
|
|
148
167
|
'Cync: 2FA code sent to your email. Enter the code as "twoFactor" in the plugin config and restart Homebridge to complete login.',
|
|
149
168
|
);
|
|
@@ -151,11 +170,11 @@ export class CyncClient {
|
|
|
151
170
|
}
|
|
152
171
|
|
|
153
172
|
// We have a 2FA code – complete login and persist token
|
|
154
|
-
this.log.info('Cync: completing 2FA login for %s',
|
|
173
|
+
this.log.info('Cync: completing 2FA login for %s', username);
|
|
155
174
|
const loginResult = await this.completeTwoFactorLogin(
|
|
156
|
-
|
|
175
|
+
username,
|
|
157
176
|
password,
|
|
158
|
-
|
|
177
|
+
trimmedCode,
|
|
159
178
|
);
|
|
160
179
|
|
|
161
180
|
// Build LAN login code
|
|
@@ -191,25 +210,22 @@ export class CyncClient {
|
|
|
191
210
|
this.applyAccessToken(tokenData);
|
|
192
211
|
|
|
193
212
|
this.log.info('Cync login successful; userId=%s (token stored)', tokenData.userId);
|
|
194
|
-
this.log.info(
|
|
195
|
-
'Cync: 2FA login complete and a token has been stored. You may now clear the "twoFactor" code from the plugin config; ' +
|
|
196
|
-
'it will only be needed again if the stored token expires or is removed.',
|
|
197
|
-
);
|
|
198
213
|
return true;
|
|
199
214
|
}
|
|
200
215
|
|
|
201
216
|
|
|
202
217
|
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
218
|
+
* Internal helper: request a 2FA email code using existing authenticate().
|
|
219
|
+
* Accepts the same username value we store in loginConfig (email address for Cync).
|
|
220
|
+
*/
|
|
221
|
+
private async requestTwoFactorCode(username: string): Promise<void> {
|
|
222
|
+
await this.authenticate(username);
|
|
207
223
|
}
|
|
208
224
|
|
|
209
225
|
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
226
|
+
* Internal helper: complete 2FA login using existing submitTwoFactor().
|
|
227
|
+
* This converts CyncLoginSession into the richer shape we want for token storage.
|
|
228
|
+
*/
|
|
213
229
|
private async completeTwoFactorLogin(
|
|
214
230
|
email: string,
|
|
215
231
|
password: string,
|
|
@@ -222,12 +238,15 @@ export class CyncClient {
|
|
|
222
238
|
}
|
|
223
239
|
> {
|
|
224
240
|
const session = await this.submitTwoFactor(email, password, code);
|
|
241
|
+
|
|
225
242
|
// Extract authorize field from session.raw (Cync returns it)
|
|
226
243
|
const raw = session.raw as Record<string, unknown>;
|
|
227
244
|
const authorize = typeof raw?.authorize === 'string' ? raw.authorize : undefined;
|
|
228
245
|
|
|
229
246
|
if (!authorize) {
|
|
230
|
-
throw new Error(
|
|
247
|
+
throw new Error(
|
|
248
|
+
'CyncClient: missing "authorize" field from login response; LAN login cannot be generated.',
|
|
249
|
+
);
|
|
231
250
|
}
|
|
232
251
|
|
|
233
252
|
const s = session as unknown as SessionWithPossibleTokens;
|
|
@@ -320,11 +339,11 @@ export class CyncClient {
|
|
|
320
339
|
* in the same process, so it works across Homebridge restarts.
|
|
321
340
|
*/
|
|
322
341
|
public async submitTwoFactor(
|
|
323
|
-
|
|
342
|
+
username: string,
|
|
324
343
|
password: string,
|
|
325
344
|
code: string,
|
|
326
345
|
): Promise<CyncLoginSession> {
|
|
327
|
-
const trimmedEmail =
|
|
346
|
+
const trimmedEmail = username.trim();
|
|
328
347
|
const trimmedCode = code.trim();
|
|
329
348
|
|
|
330
349
|
this.log.info('CyncClient: completing 2FA login for %s', trimmedEmail);
|
|
@@ -351,7 +370,6 @@ export class CyncClient {
|
|
|
351
370
|
return session;
|
|
352
371
|
}
|
|
353
372
|
|
|
354
|
-
|
|
355
373
|
/**
|
|
356
374
|
* Fetch and cache the cloud configuration (meshes/devices) for the logged-in user.
|
|
357
375
|
* Also builds HA-style LAN topology mappings:
|
package/src/cync/tcp-client.ts
CHANGED
|
@@ -14,13 +14,6 @@ const defaultLogger: CyncLogger = {
|
|
|
14
14
|
export type DeviceUpdateCallback = (payload: unknown) => void;
|
|
15
15
|
export type RawFrameListener = (frame: Buffer) => void;
|
|
16
16
|
|
|
17
|
-
interface QueuedPowerPacket {
|
|
18
|
-
deviceId: string;
|
|
19
|
-
on: boolean;
|
|
20
|
-
seq: number;
|
|
21
|
-
packet: Buffer;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
17
|
export class TcpClient {
|
|
25
18
|
public registerSwitchMapping(controllerId: number, deviceId: string): void {
|
|
26
19
|
if (!Number.isFinite(controllerId)) {
|
|
@@ -28,6 +21,7 @@ export class TcpClient {
|
|
|
28
21
|
}
|
|
29
22
|
this.controllerToDevice.set(controllerId, deviceId);
|
|
30
23
|
}
|
|
24
|
+
private commandChain: Promise<void> = Promise.resolve();
|
|
31
25
|
private homeDevices: Record<string, string[]> = {};
|
|
32
26
|
private switchIdToHomeId = new Map<number, string>();
|
|
33
27
|
private readonly log: CyncLogger;
|
|
@@ -44,9 +38,38 @@ export class TcpClient {
|
|
|
44
38
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
|
45
39
|
private rawFrameListeners: RawFrameListener[] = [];
|
|
46
40
|
private controllerToDevice = new Map<number, string>();
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
*/
|
|
47
|
+
private enqueueCommand<T>(fn: () => Promise<T>): Promise<T> {
|
|
48
|
+
let resolveWrapper: (value: T | PromiseLike<T>) => void;
|
|
49
|
+
let rejectWrapper: (reason?: unknown) => void;
|
|
50
|
+
|
|
51
|
+
const p = new Promise<T>((resolve, reject) => {
|
|
52
|
+
resolveWrapper = resolve;
|
|
53
|
+
rejectWrapper = reject;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Chain onto the existing promise
|
|
57
|
+
this.commandChain = this.commandChain
|
|
58
|
+
.then(async () => {
|
|
59
|
+
try {
|
|
60
|
+
const result = await fn();
|
|
61
|
+
resolveWrapper(result);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
rejectWrapper(err);
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
// Swallow errors in the chain so a failed command
|
|
68
|
+
// doesn't permanently block the queue.
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
50
73
|
private parseSwitchStateFrame(frame: Buffer): { controllerId: number; on: boolean; level: number } | null {
|
|
51
74
|
if (frame.length < 16) {
|
|
52
75
|
return null;
|
|
@@ -180,8 +203,7 @@ export class TcpClient {
|
|
|
180
203
|
|
|
181
204
|
/**
|
|
182
205
|
* Ensure we have an open, logged-in socket.
|
|
183
|
-
* If
|
|
184
|
-
* connection attempt via `this.connecting`.
|
|
206
|
+
* If the socket is closed or missing, attempt to reconnect.
|
|
185
207
|
*/
|
|
186
208
|
private async ensureConnected(): Promise<boolean> {
|
|
187
209
|
if (this.socket && !this.socket.destroyed) {
|
|
@@ -195,28 +217,8 @@ export class TcpClient {
|
|
|
195
217
|
return false;
|
|
196
218
|
}
|
|
197
219
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
await this.establishSocket();
|
|
201
|
-
const ok = !!(this.socket && !this.socket.destroyed);
|
|
202
|
-
if (!ok) {
|
|
203
|
-
this.log.warn(
|
|
204
|
-
'[Cync TCP] establishSocket() completed but socket is not usable.',
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
this.connecting = null;
|
|
208
|
-
return ok;
|
|
209
|
-
})().catch((err) => {
|
|
210
|
-
this.log.error(
|
|
211
|
-
'[Cync TCP] ensureConnected() connect attempt failed: %s',
|
|
212
|
-
String(err),
|
|
213
|
-
);
|
|
214
|
-
this.connecting = null;
|
|
215
|
-
return false;
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return this.connecting;
|
|
220
|
+
await this.establishSocket();
|
|
221
|
+
return !!(this.socket && !this.socket.destroyed);
|
|
220
222
|
}
|
|
221
223
|
|
|
222
224
|
/**
|
|
@@ -325,45 +327,6 @@ export class TcpClient {
|
|
|
325
327
|
return this.seq;
|
|
326
328
|
}
|
|
327
329
|
|
|
328
|
-
private async flushQueue(): Promise<void> {
|
|
329
|
-
if (this.sending) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
this.sending = true;
|
|
333
|
-
|
|
334
|
-
try {
|
|
335
|
-
while (this.sendQueue.length > 0) {
|
|
336
|
-
const item = this.sendQueue.shift();
|
|
337
|
-
if (!item) {
|
|
338
|
-
break;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const connected = await this.ensureConnected();
|
|
342
|
-
if (!connected || !this.socket || this.socket.destroyed) {
|
|
343
|
-
this.log.warn(
|
|
344
|
-
'[Cync TCP] Socket not available while flushing queue; dropping remaining %d packets.',
|
|
345
|
-
this.sendQueue.length + 1,
|
|
346
|
-
);
|
|
347
|
-
this.sendQueue.length = 0;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
this.socket.write(item.packet);
|
|
352
|
-
this.log.info(
|
|
353
|
-
'[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
|
|
354
|
-
item.deviceId,
|
|
355
|
-
String(item.on),
|
|
356
|
-
item.seq,
|
|
357
|
-
);
|
|
358
|
-
|
|
359
|
-
// Small delay so we don't hammer the bridge/device with a burst
|
|
360
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
361
|
-
}
|
|
362
|
-
} finally {
|
|
363
|
-
this.sending = false;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
330
|
private buildPowerPacket(
|
|
368
331
|
controllerId: number,
|
|
369
332
|
meshId: number,
|
|
@@ -414,9 +377,6 @@ export class TcpClient {
|
|
|
414
377
|
this.socket.destroy();
|
|
415
378
|
this.socket = null;
|
|
416
379
|
}
|
|
417
|
-
this.sendQueue.length = 0;
|
|
418
|
-
this.sending = false;
|
|
419
|
-
this.connecting = null;
|
|
420
380
|
}
|
|
421
381
|
|
|
422
382
|
public onDeviceUpdate(cb: DeviceUpdateCallback): void {
|
|
@@ -442,49 +402,58 @@ export class TcpClient {
|
|
|
442
402
|
|
|
443
403
|
/**
|
|
444
404
|
* High-level API to change switch state.
|
|
445
|
-
*
|
|
446
|
-
* serialized over a single TCP session.
|
|
405
|
+
* Ensures we have a live socket before sending and serializes commands.
|
|
447
406
|
*/
|
|
448
407
|
public async setSwitchState(
|
|
449
408
|
deviceId: string,
|
|
450
409
|
params: { on: boolean },
|
|
451
410
|
): Promise<void> {
|
|
452
|
-
|
|
453
|
-
this.
|
|
454
|
-
|
|
455
|
-
|
|
411
|
+
return this.enqueueCommand(async () => {
|
|
412
|
+
if (!this.config) {
|
|
413
|
+
this.log.warn('[Cync TCP] No config available.');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
456
416
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
417
|
+
const connected = await this.ensureConnected();
|
|
418
|
+
if (!connected || !this.socket || this.socket.destroyed) {
|
|
419
|
+
this.log.warn(
|
|
420
|
+
'[Cync TCP] Cannot send, socket not ready even after reconnect attempt.',
|
|
421
|
+
);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const device = this.findDevice(deviceId);
|
|
426
|
+
if (!device) {
|
|
427
|
+
this.log.warn('[Cync TCP] Unknown deviceId=%s', deviceId);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
462
430
|
|
|
463
|
-
|
|
464
|
-
|
|
431
|
+
const record = device as Record<string, unknown>;
|
|
432
|
+
const controllerId = Number(record.switch_controller);
|
|
433
|
+
const meshIndex = Number(record.mesh_id);
|
|
465
434
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
435
|
+
if (!Number.isFinite(controllerId) || !Number.isFinite(meshIndex)) {
|
|
436
|
+
this.log.warn(
|
|
437
|
+
'[Cync TCP] Device %s is missing LAN fields (switch_controller=%o mesh_id=%o)',
|
|
438
|
+
deviceId,
|
|
439
|
+
record.switch_controller,
|
|
440
|
+
record.mesh_id,
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
475
444
|
|
|
476
|
-
|
|
477
|
-
|
|
445
|
+
const seq = this.nextSeq();
|
|
446
|
+
const packet = this.buildPowerPacket(controllerId, meshIndex, params.on, seq);
|
|
478
447
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
448
|
+
// At this point socket has been validated above
|
|
449
|
+
this.socket.write(packet);
|
|
450
|
+
this.log.info(
|
|
451
|
+
'[Cync TCP] Sent power packet: device=%s on=%s seq=%d',
|
|
452
|
+
deviceId,
|
|
453
|
+
String(params.on),
|
|
454
|
+
seq,
|
|
455
|
+
);
|
|
484
456
|
});
|
|
485
|
-
|
|
486
|
-
// Async fire-and-forget; actual sends are serialized in flushQueue()
|
|
487
|
-
void this.flushQueue();
|
|
488
457
|
}
|
|
489
458
|
|
|
490
459
|
private findDevice(deviceId: string) {
|
|
@@ -515,9 +484,7 @@ export class TcpClient {
|
|
|
515
484
|
|
|
516
485
|
socket.on('close', () => {
|
|
517
486
|
this.log.warn('[Cync TCP] Socket closed.');
|
|
518
|
-
|
|
519
|
-
this.socket = null;
|
|
520
|
-
}
|
|
487
|
+
this.socket = null;
|
|
521
488
|
});
|
|
522
489
|
|
|
523
490
|
socket.on('error', (err) => {
|
package/src/cync/token-store.ts
CHANGED
|
@@ -13,17 +13,12 @@ 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
|
|
19
16
|
*/
|
|
20
17
|
export class CyncTokenStore {
|
|
21
|
-
private readonly dirPath: string;
|
|
22
18
|
private readonly filePath: string;
|
|
23
19
|
|
|
24
20
|
public constructor(storagePath: string) {
|
|
25
|
-
this.
|
|
26
|
-
this.filePath = path.join(this.dirPath, 'cync-tokens.json');
|
|
21
|
+
this.filePath = path.join(storagePath, 'cync-tokens.json');
|
|
27
22
|
}
|
|
28
23
|
|
|
29
24
|
public async load(): Promise<CyncTokenData | null> {
|
|
@@ -38,16 +33,12 @@ export class CyncTokenStore {
|
|
|
38
33
|
|
|
39
34
|
return data;
|
|
40
35
|
} catch {
|
|
41
|
-
// file missing or unreadable → treat as no token
|
|
42
36
|
return null;
|
|
43
37
|
}
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
public async save(data: CyncTokenData): Promise<void> {
|
|
47
41
|
const json = JSON.stringify(data, null, 2);
|
|
48
|
-
|
|
49
|
-
// Ensure directory exists before writing
|
|
50
|
-
await fs.mkdir(this.dirPath, { recursive: true });
|
|
51
42
|
await fs.writeFile(this.filePath, json, 'utf8');
|
|
52
43
|
}
|
|
53
44
|
|
|
@@ -55,7 +46,7 @@ export class CyncTokenStore {
|
|
|
55
46
|
try {
|
|
56
47
|
await fs.unlink(this.filePath);
|
|
57
48
|
} catch {
|
|
58
|
-
// ignore if missing
|
|
49
|
+
// ignore if missing
|
|
59
50
|
}
|
|
60
51
|
}
|
|
61
52
|
}
|