homebridge-roborock-matter 2.1.0

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 (59) hide show
  1. package/.gitattributes +13 -0
  2. package/CHANGELOG.md +469 -0
  3. package/LICENSE +23 -0
  4. package/README.md +45 -0
  5. package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
  6. package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
  7. package/config.schema.json +118 -0
  8. package/dist/crypto.js +65 -0
  9. package/dist/crypto.js.map +1 -0
  10. package/dist/index.js +10 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/live_message.js +25 -0
  13. package/dist/live_message.js.map +1 -0
  14. package/dist/logger.js +37 -0
  15. package/dist/logger.js.map +1 -0
  16. package/dist/matter_vacuum_accessory.js +1877 -0
  17. package/dist/matter_vacuum_accessory.js.map +1 -0
  18. package/dist/platform.js +441 -0
  19. package/dist/platform.js.map +1 -0
  20. package/dist/settings.js +8 -0
  21. package/dist/settings.js.map +1 -0
  22. package/dist/types.js +3 -0
  23. package/dist/types.js.map +1 -0
  24. package/dist/ui/index.js +741 -0
  25. package/dist/ui/index.js.map +1 -0
  26. package/docs/matter-rvc-updating-homebridge-report.md +120 -0
  27. package/homebridge-roborock-vacuum.code-workspace +7 -0
  28. package/homebridge-ui/public/index.html +257 -0
  29. package/homebridge-ui/public/index.js +1276 -0
  30. package/homebridge-ui/public/styles.css +611 -0
  31. package/homebridge-ui/server.js +3 -0
  32. package/package.json +91 -0
  33. package/roborockLib/i18n/de/translations.json +188 -0
  34. package/roborockLib/i18n/en/translations.json +208 -0
  35. package/roborockLib/i18n/es/translations.json +188 -0
  36. package/roborockLib/i18n/fr/translations.json +188 -0
  37. package/roborockLib/i18n/it/translations.json +188 -0
  38. package/roborockLib/i18n/nl/translations.json +188 -0
  39. package/roborockLib/i18n/pl/translations.json +188 -0
  40. package/roborockLib/i18n/pt/translations.json +188 -0
  41. package/roborockLib/i18n/ru/translations.json +188 -0
  42. package/roborockLib/i18n/uk/translations.json +188 -0
  43. package/roborockLib/i18n/zh-cn/translations.json +188 -0
  44. package/roborockLib/lib/RRMapParser.js +459 -0
  45. package/roborockLib/lib/b01Q7Adapter.js +468 -0
  46. package/roborockLib/lib/deviceFeatures.js +1349 -0
  47. package/roborockLib/lib/localConnector.js +629 -0
  48. package/roborockLib/lib/map/map.html +137 -0
  49. package/roborockLib/lib/map/zones.js +851 -0
  50. package/roborockLib/lib/message.js +402 -0
  51. package/roborockLib/lib/messageQueueHandler.js +411 -0
  52. package/roborockLib/lib/roborockAuth.js +178 -0
  53. package/roborockLib/lib/roborockCrypto.js +55 -0
  54. package/roborockLib/lib/roborockPackageHelper.js +142 -0
  55. package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
  56. package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
  57. package/roborockLib/lib/vacuum.js +992 -0
  58. package/roborockLib/roborockAPI.js +3635 -0
  59. package/tsconfig.roborockLib.json +17 -0
@@ -0,0 +1,741 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const crypto_1 = __importDefault(require("crypto"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const net_1 = __importDefault(require("net"));
10
+ const qrcode_1 = __importDefault(require("qrcode"));
11
+ const crypto_2 = require("../crypto");
12
+ const roborockAuth = require("../../roborockLib/lib/roborockAuth");
13
+ const ACTIVE_LOCAL_TRANSPORT_MAX_AGE_MS = 5 * 60 * 1000;
14
+ const LOCAL_CONTROL_PORT = 58867;
15
+ class RoborockUiServer {
16
+ constructor(HomebridgePluginUiServer) {
17
+ this.homebridgePluginUiServer = new HomebridgePluginUiServer();
18
+ this.homebridgeStoragePath =
19
+ this.homebridgePluginUiServer.homebridgeStoragePath;
20
+ this.homebridgePluginUiServer.onRequest("/auth/send-2fa-email", this.sendTwoFactorEmail.bind(this));
21
+ this.homebridgePluginUiServer.onRequest("/auth/verify-2fa-code", this.verifyTwoFactorCode.bind(this));
22
+ this.homebridgePluginUiServer.onRequest("/auth/login", this.loginWithPassword.bind(this));
23
+ this.homebridgePluginUiServer.onRequest("/auth/logout", this.logout.bind(this));
24
+ this.homebridgePluginUiServer.onRequest("/diagnostics/state", this.getDiagnostics.bind(this));
25
+ this.homebridgePluginUiServer.onRequest("/diagnostics/test-local", this.testLocalConnections.bind(this));
26
+ this.homebridgePluginUiServer.onRequest("/matter/pairing", this.getMatterPairing.bind(this));
27
+ this.homebridgePluginUiServer.ready();
28
+ }
29
+ getStoragePath() {
30
+ return this.homebridgeStoragePath || process.cwd();
31
+ }
32
+ async getClientId() {
33
+ const storagePath = this.getStoragePath();
34
+ if (storagePath) {
35
+ const clientIdPath = path_1.default.join(storagePath, "roborock.clientID");
36
+ try {
37
+ const stored = JSON.parse(fs_1.default.readFileSync(clientIdPath, "utf8"));
38
+ if (stored && stored.val) {
39
+ return stored.val;
40
+ }
41
+ }
42
+ catch (error) {
43
+ // Ignore and generate a new client ID.
44
+ }
45
+ const clientId = crypto_1.default.randomUUID();
46
+ fs_1.default.mkdirSync(storagePath, { recursive: true });
47
+ fs_1.default.writeFileSync(clientIdPath, JSON.stringify({ val: clientId, ack: true }, null, 2), "utf8");
48
+ return clientId;
49
+ }
50
+ return crypto_1.default.randomUUID();
51
+ }
52
+ async buildLoginApi(config) {
53
+ const clientID = await this.getClientId();
54
+ return roborockAuth.createLoginApi({
55
+ baseURL: config.baseURL || "usiot.roborock.com",
56
+ username: config.email,
57
+ clientID,
58
+ language: "en",
59
+ });
60
+ }
61
+ async sendTwoFactorEmail(payload) {
62
+ const email = payload.email;
63
+ if (!email) {
64
+ return { ok: false, message: "Email is required." };
65
+ }
66
+ try {
67
+ const loginApi = await this.buildLoginApi({
68
+ email,
69
+ baseURL: payload.baseURL,
70
+ });
71
+ await roborockAuth.requestEmailCode(loginApi, email);
72
+ return { ok: true, message: "Verification email sent." };
73
+ }
74
+ catch (error) {
75
+ console.error("2FA email request failed:", (error === null || error === void 0 ? void 0 : error.message) || error);
76
+ return {
77
+ ok: false,
78
+ message: (error === null || error === void 0 ? void 0 : error.message) || "Failed to send verification email.",
79
+ };
80
+ }
81
+ }
82
+ async verifyTwoFactorCode(payload) {
83
+ const email = payload.email;
84
+ if (!email) {
85
+ return { ok: false, message: "Email is required." };
86
+ }
87
+ if (!payload.code) {
88
+ return { ok: false, message: "Verification code is required." };
89
+ }
90
+ return this.performSignedLogin({
91
+ email,
92
+ baseURL: payload.baseURL,
93
+ requestFailedLogLabel: "2FA verification request failed:",
94
+ requestFailedFallbackMessage: "Verification failed.",
95
+ successMessage: "Login completed and token saved.",
96
+ failureLogLabel: "2FA verification failed:",
97
+ failureFallbackMessage: "Verification failed.",
98
+ performLogin: async (loginApi, nonce, k) => {
99
+ const region = roborockAuth.getRegionConfig(payload.baseURL || "usiot.roborock.com");
100
+ return roborockAuth.loginWithCode(loginApi, {
101
+ email,
102
+ code: payload.code,
103
+ country: region.country,
104
+ countryCode: region.countryCode,
105
+ k,
106
+ s: nonce,
107
+ });
108
+ },
109
+ });
110
+ }
111
+ async loginWithPassword(payload) {
112
+ const email = payload.email;
113
+ const password = payload.password;
114
+ if (!email || !password) {
115
+ return { ok: false, message: "Email and password are required." };
116
+ }
117
+ return this.performSignedLogin({
118
+ email,
119
+ baseURL: payload.baseURL,
120
+ requestFailedLogLabel: "Login request failed:",
121
+ requestFailedFallbackMessage: "Login failed.",
122
+ successMessage: "Login successful. Token saved.",
123
+ failureLogLabel: "Login failed:",
124
+ failureFallbackMessage: "Login failed. Check your credentials.",
125
+ checkTwoFactorRequired: true,
126
+ performLogin: (loginApi, nonce, k) => roborockAuth.loginByPassword(loginApi, {
127
+ email,
128
+ password,
129
+ k,
130
+ s: nonce,
131
+ }),
132
+ });
133
+ }
134
+ // Shared by verifyTwoFactorCode and loginWithPassword: both build a login
135
+ // API, sign a nonce, invoke a login function with the resulting `k`, then
136
+ // map the response to the same success/failure shape (loginWithPassword
137
+ // additionally treats code 2031 as "two-factor required").
138
+ async performSignedLogin(options) {
139
+ const { email, baseURL, requestFailedLogLabel, requestFailedFallbackMessage, successMessage, failureLogLabel, failureFallbackMessage, checkTwoFactorRequired, performLogin, } = options;
140
+ let loginResult;
141
+ try {
142
+ const loginApi = await this.buildLoginApi({ email, baseURL });
143
+ const nonce = this.buildNonce();
144
+ const signData = await roborockAuth.signRequest(loginApi, nonce);
145
+ if (!signData || !signData.k) {
146
+ return { ok: false, message: "Failed to create login signature." };
147
+ }
148
+ loginResult = await performLogin(loginApi, nonce, signData.k);
149
+ }
150
+ catch (error) {
151
+ console.error(requestFailedLogLabel, (error === null || error === void 0 ? void 0 : error.message) || error);
152
+ return {
153
+ ok: false,
154
+ message: (error === null || error === void 0 ? void 0 : error.message) || requestFailedFallbackMessage,
155
+ };
156
+ }
157
+ if (loginResult && loginResult.code === 200 && loginResult.data) {
158
+ const encrypted = (0, crypto_2.encryptSession)(loginResult.data, this.getStoragePath());
159
+ return {
160
+ ok: true,
161
+ message: successMessage,
162
+ encryptedToken: encrypted,
163
+ };
164
+ }
165
+ if (checkTwoFactorRequired && loginResult && loginResult.code === 2031) {
166
+ return {
167
+ ok: false,
168
+ twoFactorRequired: true,
169
+ message: "Two-factor authentication required.",
170
+ };
171
+ }
172
+ console.error(failureLogLabel, loginResult);
173
+ return { ok: false, message: (loginResult === null || loginResult === void 0 ? void 0 : loginResult.msg) || failureFallbackMessage };
174
+ }
175
+ async logout() {
176
+ const storagePath = this.getStoragePath();
177
+ if (!storagePath) {
178
+ return { ok: true, message: "Logged out. Token cleared." };
179
+ }
180
+ const userDataPath = path_1.default.join(storagePath, "roborock.UserData");
181
+ try {
182
+ if (fs_1.default.existsSync(userDataPath)) {
183
+ fs_1.default.unlinkSync(userDataPath);
184
+ }
185
+ }
186
+ catch (error) {
187
+ // Ignore file removal errors.
188
+ }
189
+ return { ok: true, message: "Logged out. Token cleared." };
190
+ }
191
+ async getDiagnostics() {
192
+ try {
193
+ const storagePath = this.getStoragePath();
194
+ const homeDataState = this.readJsonFile(path_1.default.join(storagePath, "roborock.HomeData"));
195
+ const userDataState = this.readJsonFile(path_1.default.join(storagePath, "roborock.UserData"));
196
+ const transportDiagnosticsState = this.readJsonFile(path_1.default.join(storagePath, "roborock.TransportDiagnostics"));
197
+ const roborockDiagnosticsState = this.readJsonFile(path_1.default.join(storagePath, "roborock.RoborockDiagnostics"));
198
+ const homeData = this.parseStatePayload(homeDataState === null || homeDataState === void 0 ? void 0 : homeDataState.val);
199
+ const transportDiagnostics = this.parseStatePayload(transportDiagnosticsState === null || transportDiagnosticsState === void 0 ? void 0 : transportDiagnosticsState.val) || {};
200
+ const roborockDiagnostics = this.parseStatePayload(roborockDiagnosticsState === null || roborockDiagnosticsState === void 0 ? void 0 : roborockDiagnosticsState.val) || {};
201
+ const products = Array.isArray(homeData === null || homeData === void 0 ? void 0 : homeData.products)
202
+ ? homeData.products
203
+ : [];
204
+ const devices = this.collectDevices(homeData);
205
+ const diagnostics = devices.map((device) => {
206
+ var _a, _b;
207
+ const product = products.find((entry) => entry.id == device.productId);
208
+ const deviceModel = this.firstNonEmptyString([
209
+ device.model,
210
+ device.productModel,
211
+ device.productCode,
212
+ device.modelId,
213
+ ]);
214
+ const productModel = this.firstNonEmptyString([
215
+ product === null || product === void 0 ? void 0 : product.model,
216
+ product === null || product === void 0 ? void 0 : product.productModel,
217
+ product === null || product === void 0 ? void 0 : product.productCode,
218
+ product === null || product === void 0 ? void 0 : product.modelId,
219
+ ]);
220
+ const resolvedModel = deviceModel || productModel || "unknown";
221
+ const localKey = this.firstNonEmptyString([device.localKey]);
222
+ const transport = transportDiagnostics[device.duid] || {};
223
+ const roborockDiagnostic = roborockDiagnostics[device.duid] || {};
224
+ const connection = this.describeConnectionState(device, transport, Boolean(localKey));
225
+ return {
226
+ name: device.name || device.duid || "Unknown device",
227
+ duid: device.duid || "",
228
+ serialNumber: device.sn || null,
229
+ productId: device.productId || null,
230
+ resolvedModel,
231
+ deviceModel: deviceModel || null,
232
+ productModel: productModel || null,
233
+ hasLocalKey: Boolean(localKey),
234
+ connectionStatus: connection.status,
235
+ connectionHealth: connection.health,
236
+ connectionHint: connection.hint,
237
+ localConnectivityState: connection.status,
238
+ localIp: transport.localIp || null,
239
+ localDiscoveryState: transport.localDiscoveryState || null,
240
+ tcpConnectionState: transport.tcpConnectionState || null,
241
+ isRemote: (_a = transport.isRemote) !== null && _a !== void 0 ? _a : null,
242
+ remoteReason: transport.remoteReason || null,
243
+ lastTransport: transport.lastTransport || null,
244
+ lastTransportReason: transport.lastTransportReason || null,
245
+ lastCommandMethod: transport.lastCommandMethod || null,
246
+ transportUpdatedAt: transport.updatedAt || null,
247
+ roborockDiagnosticUpdatedAt: roborockDiagnostic.updatedAt || null,
248
+ lastStatusDiagnostic: roborockDiagnostic.lastStatus || null,
249
+ lastServerTimerDiagnostic: roborockDiagnostic.lastServerTimer || null,
250
+ lastTimerDiagnostic: roborockDiagnostic.lastTimer || null,
251
+ lastCloudMessageDiagnostic: roborockDiagnostic.lastCloudMessage || null,
252
+ lastLocalMessageDiagnostic: roborockDiagnostic.lastLocalMessage || null,
253
+ homeDataSource: Array.isArray(homeData === null || homeData === void 0 ? void 0 : homeData.receivedDevices) &&
254
+ homeData.receivedDevices.some((entry) => entry.duid === device.duid)
255
+ ? "receivedDevices"
256
+ : "devices",
257
+ online: (_b = device.online) !== null && _b !== void 0 ? _b : null,
258
+ };
259
+ });
260
+ return {
261
+ ok: true,
262
+ generatedAt: new Date().toISOString(),
263
+ pluginVersion: this.getPackageVersion(),
264
+ nodeVersion: process.version,
265
+ storagePath,
266
+ hasEncryptedToken: Boolean(userDataState === null || userDataState === void 0 ? void 0 : userDataState.val),
267
+ hasHomeData: Boolean(homeData),
268
+ deviceCount: diagnostics.length,
269
+ devices: diagnostics,
270
+ };
271
+ }
272
+ catch (error) {
273
+ return {
274
+ ok: false,
275
+ message: (error === null || error === void 0 ? void 0 : error.message) || "Failed to load diagnostics.",
276
+ };
277
+ }
278
+ }
279
+ async testLocalConnections(payload) {
280
+ const startedAt = Date.now();
281
+ const diagnostics = await this.getDiagnostics();
282
+ if (!diagnostics.ok) {
283
+ return diagnostics;
284
+ }
285
+ const devices = Array.isArray(diagnostics.devices)
286
+ ? diagnostics.devices.filter((device) => {
287
+ return !(payload === null || payload === void 0 ? void 0 : payload.duid) || device.duid === payload.duid;
288
+ })
289
+ : [];
290
+ if ((payload === null || payload === void 0 ? void 0 : payload.duid) && devices.length === 0) {
291
+ return {
292
+ ok: false,
293
+ message: "No matching device was found in cached HomeData.",
294
+ };
295
+ }
296
+ const results = await Promise.all(devices.map((device) => this.testLocalConnection(device, Boolean(payload === null || payload === void 0 ? void 0 : payload.cloudOnlyMode))));
297
+ return {
298
+ ok: true,
299
+ generatedAt: new Date().toISOString(),
300
+ durationMs: Date.now() - startedAt,
301
+ deviceCount: results.length,
302
+ devices: results,
303
+ };
304
+ }
305
+ async getMatterPairing() {
306
+ try {
307
+ const storagePath = this.getStoragePath();
308
+ const homeDataState = this.readJsonFile(path_1.default.join(storagePath, "roborock.HomeData"));
309
+ const homeData = this.parseStatePayload(homeDataState === null || homeDataState === void 0 ? void 0 : homeDataState.val);
310
+ const devices = this.collectDevices(homeData);
311
+ // Index devices by BOTH duid and serial number. The Matter accessory
312
+ // serial in the commissioning file is the robot's SN for vacuum nodes,
313
+ // so a duid-only map never matched and every node fell back to the
314
+ // generic bridge label.
315
+ const deviceByIdentifier = new Map();
316
+ for (const device of devices) {
317
+ if (typeof device.duid === "string" && device.duid) {
318
+ deviceByIdentifier.set(device.duid, device);
319
+ }
320
+ if (typeof device.sn === "string" && device.sn) {
321
+ deviceByIdentifier.set(device.sn, device);
322
+ }
323
+ }
324
+ const matterPaths = this.getMatterSearchPaths(storagePath);
325
+ const files = this.findActiveCommissioningFiles(matterPaths);
326
+ const entries = await Promise.all(files.map(async (filePath) => {
327
+ const commissioning = this.readJsonFile(filePath);
328
+ if (!commissioning) {
329
+ return null;
330
+ }
331
+ return this.buildMatterPairingEntry(commissioning, filePath, deviceByIdentifier);
332
+ }));
333
+ const pairingEntries = entries
334
+ .filter((entry) => Boolean(entry))
335
+ .sort((a, b) => {
336
+ var _a, _b;
337
+ const weight = { bridge: 0, vacuum: 1, unknown: 2 };
338
+ return (((_a = weight[a.kind]) !== null && _a !== void 0 ? _a : 2) -
339
+ ((_b = weight[b.kind]) !== null && _b !== void 0 ? _b : 2) ||
340
+ String(a.name).localeCompare(String(b.name)));
341
+ });
342
+ return {
343
+ ok: true,
344
+ generatedAt: new Date().toISOString(),
345
+ storagePath,
346
+ matterPath: matterPaths[0] || path_1.default.join(storagePath, "matter"),
347
+ matterPaths,
348
+ hasMatterDirectory: matterPaths.some((matterPath) => fs_1.default.existsSync(matterPath)),
349
+ entries: pairingEntries,
350
+ };
351
+ }
352
+ catch (error) {
353
+ return {
354
+ ok: false,
355
+ message: (error === null || error === void 0 ? void 0 : error.message) || "Failed to load Matter pairing codes.",
356
+ };
357
+ }
358
+ }
359
+ getMatterSearchPaths(storagePath) {
360
+ return Array.from(new Set([
361
+ path_1.default.join(storagePath, "matter"),
362
+ "/var/lib/homebridge/matter",
363
+ "/homebridge/matter",
364
+ ]));
365
+ }
366
+ findActiveCommissioningFiles(matterPaths) {
367
+ const files = [];
368
+ const seen = new Set();
369
+ for (const matterPath of matterPaths) {
370
+ if (!fs_1.default.existsSync(matterPath)) {
371
+ continue;
372
+ }
373
+ try {
374
+ for (const entry of fs_1.default.readdirSync(matterPath, {
375
+ withFileTypes: true,
376
+ })) {
377
+ if (!entry.isDirectory() || entry.name.includes(".")) {
378
+ continue;
379
+ }
380
+ const filePath = path_1.default.join(matterPath, entry.name, "commissioning.json");
381
+ if (!fs_1.default.existsSync(filePath)) {
382
+ continue;
383
+ }
384
+ const key = this.getRealPath(filePath);
385
+ if (seen.has(key)) {
386
+ continue;
387
+ }
388
+ seen.add(key);
389
+ files.push(filePath);
390
+ }
391
+ }
392
+ catch (_a) {
393
+ // Ignore unreadable Matter storage locations and continue searching.
394
+ }
395
+ }
396
+ return files;
397
+ }
398
+ getRealPath(filePath) {
399
+ try {
400
+ return fs_1.default.realpathSync(filePath);
401
+ }
402
+ catch (_a) {
403
+ return path_1.default.resolve(filePath);
404
+ }
405
+ }
406
+ async buildMatterPairingEntry(commissioning, filePath, deviceByIdentifier) {
407
+ const serialNumber = this.firstNonEmptyString([commissioning.serialNumber]);
408
+ const matchedDevice = serialNumber
409
+ ? deviceByIdentifier.get(serialNumber)
410
+ : undefined;
411
+ const kind = matchedDevice ? "vacuum" : "bridge";
412
+ const manualPairingCode = this.firstNonEmptyString([
413
+ commissioning.manualPairingCode,
414
+ ]);
415
+ const qrCode = this.firstNonEmptyString([commissioning.qrCode]);
416
+ const setupCode = manualPairingCode
417
+ ? manualPairingCode.replace(/\D/g, "")
418
+ : null;
419
+ const qrCodeDataUrl = qrCode
420
+ ? await qrcode_1.default.toDataURL(qrCode, {
421
+ errorCorrectionLevel: "M",
422
+ margin: 1,
423
+ width: 180,
424
+ })
425
+ : null;
426
+ return {
427
+ id: path_1.default.basename(path_1.default.dirname(filePath)),
428
+ kind,
429
+ name: (matchedDevice === null || matchedDevice === void 0 ? void 0 : matchedDevice.name) ||
430
+ (kind === "bridge" ? "Matter Roborock Bridge" : "Matter accessory"),
431
+ serialNumber: serialNumber || null,
432
+ matchedDuid: (matchedDevice === null || matchedDevice === void 0 ? void 0 : matchedDevice.duid) || null,
433
+ matchedSerial: (matchedDevice === null || matchedDevice === void 0 ? void 0 : matchedDevice.sn) || null,
434
+ qrCode,
435
+ qrCodeDataUrl,
436
+ manualPairingCode,
437
+ setupCode,
438
+ passcode: commissioning.passcode === undefined || commissioning.passcode === null
439
+ ? null
440
+ : String(commissioning.passcode),
441
+ discriminator: commissioning.discriminator === undefined ||
442
+ commissioning.discriminator === null
443
+ ? null
444
+ : String(commissioning.discriminator),
445
+ commissioned: Boolean(commissioning.commissioned),
446
+ fabricCount: typeof commissioning.fabricCount === "number"
447
+ ? commissioning.fabricCount
448
+ : null,
449
+ updatedAt: this.getFileUpdatedAt(filePath),
450
+ hint: kind === "bridge"
451
+ ? "Scan this QR code first when adding the Roborock child/daughter bridge to Apple Home."
452
+ : "Use this 11-digit setup code if Apple Home asks to add the external Roborock vacuum after the bridge is paired.",
453
+ };
454
+ }
455
+ getFileUpdatedAt(filePath) {
456
+ try {
457
+ return fs_1.default.statSync(filePath).mtime.toISOString();
458
+ }
459
+ catch (_a) {
460
+ return null;
461
+ }
462
+ }
463
+ async testLocalConnection(device, cloudOnlyMode) {
464
+ const localIp = device.localIp;
465
+ const port = LOCAL_CONTROL_PORT;
466
+ const cloudFallbackLikely = device.lastTransport === "cloud" ||
467
+ device.connectionStatus === "Cloud fallback";
468
+ const baseResult = {
469
+ name: device.name || device.duid || "Unknown device",
470
+ duid: device.duid || "",
471
+ localIp: localIp || null,
472
+ port,
473
+ checkedAt: new Date().toISOString(),
474
+ cloudFallbackLikely,
475
+ cachedConnectionStatus: device.connectionStatus || "unknown",
476
+ cachedTcpState: device.tcpConnectionState || "n/a",
477
+ cachedLastTransport: device.lastTransport || "n/a",
478
+ cachedLastReason: device.lastTransportReason || "n/a",
479
+ cachedTransportUpdatedAt: device.transportUpdatedAt || null,
480
+ connectionSource: "tcp-probe",
481
+ latencyMs: null,
482
+ };
483
+ const skip = (message) => ({
484
+ ...baseResult,
485
+ status: "skipped",
486
+ health: "warn",
487
+ message,
488
+ });
489
+ if (cloudOnlyMode) {
490
+ return skip("Use Roborock cloud only is enabled, so local LAN probing is skipped until cloud-only mode is disabled and Homebridge is restarted.");
491
+ }
492
+ if (!device.hasLocalKey) {
493
+ return skip("No local credential is cached for this vacuum, so a LAN control test cannot run yet.");
494
+ }
495
+ if (device.online === false) {
496
+ return skip("Roborock currently reports this vacuum offline. Wake the vacuum or place it on the dock, then test again.");
497
+ }
498
+ if (!localIp) {
499
+ return skip("No local IP address is cached yet. Let the plugin complete startup or press Refresh after the vacuum wakes up.");
500
+ }
501
+ const activeLocalConnection = this.describeActiveLocalConnection(device);
502
+ if (activeLocalConnection) {
503
+ return {
504
+ ...baseResult,
505
+ status: "passed",
506
+ health: "good",
507
+ connectionSource: activeLocalConnection.source,
508
+ message: activeLocalConnection.message,
509
+ };
510
+ }
511
+ try {
512
+ const probe = await this.probeTcp(localIp, port, 3500);
513
+ return {
514
+ ...baseResult,
515
+ status: "passed",
516
+ health: "good",
517
+ latencyMs: probe.latencyMs,
518
+ message: `TCP probe reached ${localIp}:${port} in ${probe.latencyMs} ms.`,
519
+ };
520
+ }
521
+ catch (error) {
522
+ return {
523
+ ...baseResult,
524
+ status: "failed",
525
+ health: "warn",
526
+ message: `Could not open a TCP connection to ${localIp}:${port}. ${this.formatProbeError(error)}`,
527
+ };
528
+ }
529
+ }
530
+ describeActiveLocalConnection(device) {
531
+ const localIp = device.localIp;
532
+ const port = LOCAL_CONTROL_PORT;
533
+ const lastCommand = device.lastCommandMethod
534
+ ? ` Last local command: ${device.lastCommandMethod}.`
535
+ : "";
536
+ if (device.tcpConnectionState === "connected") {
537
+ return {
538
+ source: "cached-active-tcp",
539
+ message: `Homebridge already has an active LAN TCP connection to ${localIp}:${port}, so the diagnostics test did not open a second probe.${lastCommand}`,
540
+ };
541
+ }
542
+ const transportUpdatedAt = this.parseTimestamp(device.transportUpdatedAt);
543
+ if (device.lastTransport === "local" &&
544
+ transportUpdatedAt !== null &&
545
+ Date.now() - transportUpdatedAt <= ACTIVE_LOCAL_TRANSPORT_MAX_AGE_MS) {
546
+ return {
547
+ source: "cached-recent-local-command",
548
+ message: `Homebridge used LAN control for this vacuum within the last ${this.formatAge(Date.now() - transportUpdatedAt)}, so the cached local path is healthy.${lastCommand}`,
549
+ };
550
+ }
551
+ return null;
552
+ }
553
+ parseTimestamp(value) {
554
+ if (typeof value !== "string" || !value.trim()) {
555
+ return null;
556
+ }
557
+ const parsed = Date.parse(value);
558
+ return Number.isNaN(parsed) ? null : parsed;
559
+ }
560
+ formatAge(ageMs) {
561
+ const seconds = Math.max(0, Math.round(ageMs / 1000));
562
+ if (seconds < 60) {
563
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
564
+ }
565
+ const minutes = Math.round(seconds / 60);
566
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
567
+ }
568
+ probeTcp(host, port, timeoutMs) {
569
+ return new Promise((resolve, reject) => {
570
+ const startedAt = Date.now();
571
+ const socket = new net_1.default.Socket();
572
+ let settled = false;
573
+ // Ensure socket doesn't keep process alive
574
+ socket.unref();
575
+ const finish = (error) => {
576
+ if (settled) {
577
+ return;
578
+ }
579
+ settled = true;
580
+ clearTimeout(timer);
581
+ socket.removeAllListeners();
582
+ // Ensure socket is properly destroyed
583
+ try {
584
+ socket.destroy();
585
+ }
586
+ catch (_a) {
587
+ // Ignore errors during destroy
588
+ }
589
+ if (error) {
590
+ reject(error);
591
+ return;
592
+ }
593
+ resolve({ latencyMs: Date.now() - startedAt });
594
+ };
595
+ const timer = setTimeout(() => {
596
+ finish(new Error(`Timed out after ${timeoutMs} ms.`));
597
+ }, timeoutMs);
598
+ socket.once("connect", () => finish());
599
+ socket.once("error", (error) => finish(error));
600
+ socket.connect(port, host);
601
+ });
602
+ }
603
+ formatProbeError(error) {
604
+ const code = (error === null || error === void 0 ? void 0 : error.code) ? `${error.code}: ` : "";
605
+ const message = (error === null || error === void 0 ? void 0 : error.message) || String(error);
606
+ return `${code}${message}`;
607
+ }
608
+ buildNonce() {
609
+ return crypto_1.default
610
+ .randomBytes(12)
611
+ .toString("base64")
612
+ .substring(0, 16)
613
+ .replace(/\+/g, "X")
614
+ .replace(/\//g, "Y");
615
+ }
616
+ readJsonFile(filePath) {
617
+ try {
618
+ if (!fs_1.default.existsSync(filePath)) {
619
+ return null;
620
+ }
621
+ return JSON.parse(fs_1.default.readFileSync(filePath, "utf8"));
622
+ }
623
+ catch (_a) {
624
+ return null;
625
+ }
626
+ }
627
+ parseStatePayload(value) {
628
+ if (typeof value !== "string") {
629
+ return null;
630
+ }
631
+ try {
632
+ return JSON.parse(value);
633
+ }
634
+ catch (_a) {
635
+ return null;
636
+ }
637
+ }
638
+ collectDevices(homeData) {
639
+ if (!homeData) {
640
+ return [];
641
+ }
642
+ const devices = Array.isArray(homeData.devices) ? homeData.devices : [];
643
+ const receivedDevices = Array.isArray(homeData.receivedDevices)
644
+ ? homeData.receivedDevices
645
+ : [];
646
+ return [...devices, ...receivedDevices];
647
+ }
648
+ firstNonEmptyString(values) {
649
+ for (const value of values) {
650
+ if (typeof value === "string" && value.trim()) {
651
+ return value.trim();
652
+ }
653
+ }
654
+ return null;
655
+ }
656
+ describeConnectionState(device, transport, hasLocalCredentials) {
657
+ const tcpState = transport.tcpConnectionState;
658
+ const lastTransportReason = transport.lastTransportReason || transport.remoteReason || null;
659
+ const hasLocalIp = Boolean(transport.localIp);
660
+ if (lastTransportReason === "cloud-only-mode" ||
661
+ transport.localDiscoveryState === "disabled" ||
662
+ tcpState === "disabled") {
663
+ return {
664
+ status: "Cloud only",
665
+ health: "good",
666
+ hint: "Cloud-only mode is enabled, so local LAN discovery and local TCP control are disabled for this plugin.",
667
+ };
668
+ }
669
+ if (tcpState === "connected") {
670
+ return {
671
+ status: "Local connected",
672
+ health: "good",
673
+ hint: "The plugin has an active LAN TCP connection to this vacuum.",
674
+ };
675
+ }
676
+ if (device.online === false || lastTransportReason === "device-offline") {
677
+ return {
678
+ status: "Device offline",
679
+ health: "warn",
680
+ hint: hasLocalCredentials
681
+ ? "Roborock currently reports this vacuum offline. Local credentials are available, but the plugin cannot use them until the vacuum wakes up and rejoins Wi-Fi."
682
+ : "Roborock currently reports this vacuum offline, and no local credentials were found for LAN control.",
683
+ };
684
+ }
685
+ if (transport.lastTransport === "cloud") {
686
+ return {
687
+ status: "Cloud fallback",
688
+ health: "warn",
689
+ hint: hasLocalCredentials
690
+ ? "The plugin has local credentials but the last command used Roborock cloud transport, usually because LAN TCP was not connected at that moment."
691
+ : "The last command used Roborock cloud transport because local LAN credentials are not available.",
692
+ };
693
+ }
694
+ if (hasLocalCredentials && hasLocalIp) {
695
+ return {
696
+ status: "Ready for local connection",
697
+ health: "warn",
698
+ hint: "The plugin has local credentials and a discovered IP address, but no active LAN TCP connection is currently cached.",
699
+ };
700
+ }
701
+ if (hasLocalCredentials) {
702
+ return {
703
+ status: "Local credentials available",
704
+ health: "warn",
705
+ hint: "The plugin has the credential needed for LAN control, but it has not discovered or connected to the vacuum locally yet.",
706
+ };
707
+ }
708
+ return {
709
+ status: "Cloud-only fallback likely",
710
+ health: "warn",
711
+ hint: "No local LAN credential was found for this vacuum, so the plugin will likely rely on Roborock cloud transport.",
712
+ };
713
+ }
714
+ getPackageVersion() {
715
+ try {
716
+ const packageJson = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, "../../package.json"), "utf8"));
717
+ return packageJson.version || "unknown";
718
+ }
719
+ catch (_a) {
720
+ return "unknown";
721
+ }
722
+ }
723
+ }
724
+ // IMPORTANT: Use Function constructor to create a dynamic import that TypeScript won't transform
725
+ //
726
+ // Background: @homebridge/plugin-ui-utils v2+ is a pure ES module that cannot be loaded with require()
727
+ // in Node.js 18+. Normally we would use `await import('@homebridge/plugin-ui-utils')`, but because
728
+ // this project uses TypeScript with "module": "commonjs" in tsconfig.json, TypeScript transforms
729
+ // dynamic imports into require() calls in the compiled output, which defeats the purpose.
730
+ //
731
+ // Solution: Using the Function constructor prevents TypeScript from transforming the import statement.
732
+ // The Function constructor is evaluated at runtime, so TypeScript cannot statically analyze or transform it.
733
+ // This is the recommended workaround for ES module/CommonJS interop when using TypeScript with CommonJS output.
734
+ //
735
+ // Security note: This is safe because the module specifier is a hardcoded string literal, not user input.
736
+ (async () => {
737
+ const dynamicImport = new Function("specifier", "return import(specifier)");
738
+ const { HomebridgePluginUiServer } = await dynamicImport("@homebridge/plugin-ui-utils");
739
+ new RoborockUiServer(HomebridgePluginUiServer);
740
+ })();
741
+ //# sourceMappingURL=index.js.map