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,3635 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const axios = require("axios");
7
+ const crypto = require("crypto");
8
+
9
+ const roborockAuth = require("./lib/roborockAuth");
10
+
11
+ const rrLocalConnector = require("./lib/localConnector").localConnector;
12
+ const roborock_mqtt_connector =
13
+ require("./lib/roborock_mqtt_connector").roborock_mqtt_connector;
14
+ const rrMessage = require("./lib/message").message;
15
+ const vacuum_class = require("./lib/vacuum").vacuum;
16
+ const roborockPackageHelper =
17
+ require("./lib/roborockPackageHelper").roborockPackageHelper;
18
+ const deviceFeatures = require("./lib/deviceFeatures").deviceFeatures;
19
+ const messageQueueHandler =
20
+ require("./lib/messageQueueHandler").messageQueueHandler;
21
+ const roborockCrypto = require("./lib/roborockCrypto");
22
+ const b01Q7Adapter = require("./lib/b01Q7Adapter");
23
+
24
+ const PERSISTED_STATE_IDS = new Set([
25
+ "UserData",
26
+ "clientID",
27
+ "HomeData",
28
+ "RoomMappings",
29
+ "B01Rooms",
30
+ "TransportDiagnostics",
31
+ "RoborockDiagnostics",
32
+ ]);
33
+
34
+ const dockingStationStates = [
35
+ "cleanFluidStatus",
36
+ "waterBoxFilterStatus",
37
+ "dustBagStatus",
38
+ "dirtyWaterBoxStatus",
39
+ "clearWaterBoxStatus",
40
+ "isUpdownWaterReady",
41
+ ];
42
+
43
+ // Commands that are forwarded to vacuums[duid].command() as-is, without any
44
+ // command-specific handling in startCommand.
45
+ const SIMPLE_VACUUM_COMMANDS = new Set([
46
+ "app_zoned_clean",
47
+ "app_goto_target",
48
+ "app_start",
49
+ "app_stop",
50
+ "stop_zoned_clean",
51
+ "app_pause",
52
+ "app_charge",
53
+ "find_me",
54
+ "app_segment_clean_by_ids",
55
+ "load_multi_map",
56
+ ]);
57
+
58
+ const TRANSIENT_ERROR_LOG_THROTTLE_MS = 6 * 60 * 60 * 1000;
59
+ const MATTER_CLEAN_MODE_COMMAND_TIMEOUT_MS = 2000;
60
+ // How long to wait before retrying to cache rooms for a saved map that did not
61
+ // return room segments. Retrying lets newly named/segmented maps appear without
62
+ // switching maps on every poll cycle.
63
+ const SERVICE_AREA_ROOM_MAP_REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
64
+
65
+ class Roborock {
66
+ constructor(options) {
67
+ this.bInited = false;
68
+
69
+ this.config = {
70
+ ...options,
71
+ cloudOnlyMode: Boolean(options.cloudOnlyMode),
72
+ };
73
+
74
+ this.updateInterval = options.updateInterval || 180;
75
+ this.log = options.log || console;
76
+ this.language = options.language || "en";
77
+
78
+ this.localKeys = null;
79
+ this.localL01Nonces = new Map();
80
+ this.roomIDs = {};
81
+ this.vacuums = {};
82
+ this.initializedVacuumDuids = new Set();
83
+ this.socket = null;
84
+
85
+ this.objects = {};
86
+ this.states = {};
87
+ this.roomMappings = this.getPersistedRoomMappings();
88
+
89
+ this.idCounter = 0;
90
+ this.nonce = crypto.randomBytes(16);
91
+ this.messageQueue = new Map();
92
+
93
+ this.roborockPackageHelper = new roborockPackageHelper(this);
94
+
95
+ this.localConnector = new rrLocalConnector(this);
96
+ this.rr_mqtt_connector = new roborock_mqtt_connector(this);
97
+ this.message = new rrMessage(this);
98
+
99
+ this.messageQueueHandler = new messageQueueHandler(this);
100
+
101
+ this.pendingRequests = new Map();
102
+
103
+ this.localDevices = {};
104
+ this.remoteDevices = new Set();
105
+
106
+ this.scenesData = null; // Store scenes data locally
107
+
108
+ this.name = "roborock";
109
+ this.deviceNotify = null;
110
+ this.serviceAreaRoomMapRefreshAttempts = new Map();
111
+ this.matterUnsupportedSettingCommands = new Set();
112
+ this.baseURL = options.baseURL || "usiot.roborock.com";
113
+
114
+ this.userData = options.userData || null;
115
+ this.authState = {
116
+ twoFactorRequired: false,
117
+ statusMessage: "",
118
+ };
119
+ this.pendingAuth = null;
120
+ this.persistBasePath = null;
121
+ this.errorLogThrottleMs =
122
+ typeof options.errorLogThrottleMs === "number"
123
+ ? options.errorLogThrottleMs
124
+ : TRANSIENT_ERROR_LOG_THROTTLE_MS;
125
+ this.errorLogThrottle = new Map();
126
+ this.now =
127
+ typeof options.now === "function" ? options.now : () => Date.now();
128
+ }
129
+
130
+ isInited() {
131
+ return this.bInited;
132
+ }
133
+
134
+ isCloudOnlyModeEnabled() {
135
+ return Boolean(this.config.cloudOnlyMode);
136
+ }
137
+
138
+ getKnownLocalIp(duid) {
139
+ if (this.localDevices && typeof this.localDevices[duid] == "string") {
140
+ return this.localDevices[duid];
141
+ }
142
+
143
+ const diagnostics = this.getTransportDiagnostics();
144
+ const diagnosticEntry =
145
+ diagnostics && typeof diagnostics[duid] == "object"
146
+ ? diagnostics[duid]
147
+ : null;
148
+ if (
149
+ diagnosticEntry &&
150
+ typeof diagnosticEntry.localIp == "string" &&
151
+ diagnosticEntry.localIp
152
+ ) {
153
+ return diagnosticEntry.localIp;
154
+ }
155
+
156
+ const networkInfo = this.getStateAsync(`Devices.${duid}.networkInfo.ip`);
157
+ if (networkInfo && typeof networkInfo.val == "string" && networkInfo.val) {
158
+ return networkInfo.val;
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ async ensureLocalConnection(duid) {
165
+ if (this.isCloudOnlyModeEnabled()) {
166
+ return false;
167
+ }
168
+
169
+ if (this.localConnector.isConnected(duid)) {
170
+ return true;
171
+ }
172
+
173
+ const localIp = this.getKnownLocalIp(duid);
174
+ if (!localIp) {
175
+ await this.updateTransportDiagnostics(duid, {
176
+ localDiscoveryState: "not-discovered",
177
+ lastTransportReason: "missing-local-ip",
178
+ });
179
+ return false;
180
+ }
181
+
182
+ await this.localConnector.ensureConnected(duid, localIp);
183
+ return Boolean(this.localConnector.isConnected(duid));
184
+ }
185
+
186
+ setInterval(callback, interval, ...args) {
187
+ return setInterval(() => callback(...args), interval);
188
+ }
189
+
190
+ clearInterval(interval) {
191
+ clearInterval(interval);
192
+ }
193
+
194
+ setTimeout(callback, timeout, ...args) {
195
+ return setTimeout(() => callback(...args), timeout);
196
+ }
197
+
198
+ clearTimeout(timeout) {
199
+ clearTimeout(timeout);
200
+ }
201
+
202
+ //dummy function for calling setObjectNotExistsAsync
203
+ async setObjectNotExistsAsync(id, obj) {}
204
+
205
+ //dummy function for calling setObjectAsync
206
+ async setObjectAsync(id, obj) {}
207
+
208
+ //dummy function for calling getObjectAsync
209
+ async getObjectAsync(id) {}
210
+
211
+ //dummy function for calling delObjectAsync
212
+ async delObjectAsync(id) {}
213
+
214
+ getStateAsync(id) {
215
+ try {
216
+ if (PERSISTED_STATE_IDS.has(id)) {
217
+ // Cache persisted state in memory after the first disk read so repeated
218
+ // reads (HomeData, RoomMappings, TransportDiagnostics) do not re-read and
219
+ // re-parse the file on every status lookup or command. setStateAsync and
220
+ // deleteStateAsync keep this cache in sync with the persisted file.
221
+ if (Object.prototype.hasOwnProperty.call(this.states, id)) {
222
+ return this.states[id];
223
+ }
224
+
225
+ const loaded = this.readPersistedState(id);
226
+ this.states[id] = loaded;
227
+ return loaded;
228
+ }
229
+
230
+ return this.states[id];
231
+ } catch (error) {
232
+ if (error && error.code == "ENOENT") {
233
+ return null;
234
+ }
235
+ this.log.error(`getStateAsync: ${error}`);
236
+ }
237
+
238
+ return null;
239
+ }
240
+
241
+ readPersistedState(id) {
242
+ const persistPath = this.getPersistPath(id);
243
+ if (fs.existsSync(persistPath)) {
244
+ return JSON.parse(fs.readFileSync(persistPath, "utf8"));
245
+ }
246
+
247
+ const legacyPath = this.getLegacyPersistPath(id);
248
+ if (legacyPath && fs.existsSync(legacyPath)) {
249
+ const legacyState = JSON.parse(fs.readFileSync(legacyPath, "utf8"));
250
+ this.tryMigrateLegacyStateFile(id, legacyState, legacyPath, persistPath);
251
+ return legacyState;
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ async setStateAsync(id, state) {
258
+ try {
259
+ if (PERSISTED_STATE_IDS.has(id)) {
260
+ const persistPath = this.getPersistPath(id);
261
+ fs.mkdirSync(path.dirname(persistPath), { recursive: true });
262
+ fs.writeFileSync(persistPath, JSON.stringify(state, null, 2, "utf8"));
263
+ }
264
+
265
+ this.states[id] = state;
266
+
267
+ if (this.deviceNotify && (id == "HomeData" || id == "CloudMessage")) {
268
+ this.deviceNotify(id, state);
269
+ }
270
+ } catch (error) {
271
+ if (PERSISTED_STATE_IDS.has(id) && error && error.code == "EACCES") {
272
+ try {
273
+ const fallbackPath = path.join(
274
+ this.forceTemporaryPersistPath(),
275
+ `roborock.${id}`
276
+ );
277
+ fs.writeFileSync(
278
+ fallbackPath,
279
+ JSON.stringify(state, null, 2, "utf8")
280
+ );
281
+ this.states[id] = state;
282
+ this.log.warn(
283
+ `Write access denied for persistent state. Saved '${id}' in temporary path '${fallbackPath}'.`
284
+ );
285
+ return;
286
+ } catch (fallbackError) {
287
+ this.log.error(`setStateAsync fallback failed: ${fallbackError}`);
288
+ }
289
+ }
290
+ this.log.error(`setStateAsync: ${error}`);
291
+ }
292
+ }
293
+
294
+ async setStateChangedAsync(id, state) {
295
+ await this.setStateAsync(id, state);
296
+ }
297
+
298
+ async deleteStateAsync(id) {
299
+ try {
300
+ if (PERSISTED_STATE_IDS.has(id)) {
301
+ const persistPath = this.getPersistPath(id);
302
+ if (fs.existsSync(persistPath)) {
303
+ fs.unlinkSync(persistPath);
304
+ }
305
+
306
+ const legacyPath = this.getLegacyPersistPath(id);
307
+ if (
308
+ legacyPath &&
309
+ legacyPath !== persistPath &&
310
+ fs.existsSync(legacyPath)
311
+ ) {
312
+ fs.unlinkSync(legacyPath);
313
+ }
314
+ }
315
+
316
+ delete this.states[id];
317
+ } catch (error) {
318
+ this.log.error(`deleteStateAsync: ${error}`);
319
+ }
320
+ }
321
+
322
+ subscribeStates(id) {
323
+ this.log.debug(`subscribeStates: ${id}`);
324
+ }
325
+
326
+ getPersistPath(id) {
327
+ const basePath = this.resolvePersistBasePath();
328
+ return path.join(basePath, `roborock.${id}`);
329
+ }
330
+
331
+ resolvePersistBasePath() {
332
+ if (this.persistBasePath) {
333
+ return this.persistBasePath;
334
+ }
335
+
336
+ const candidates = [];
337
+ if (this.config.storagePath) {
338
+ candidates.push(this.config.storagePath);
339
+ }
340
+ if (process.env.HOMEBRIDGE_STORAGE_PATH) {
341
+ candidates.push(process.env.HOMEBRIDGE_STORAGE_PATH);
342
+ }
343
+ candidates.push(path.resolve(__dirname, "./data"));
344
+ candidates.push(path.join(os.tmpdir(), "homebridge-roborock-vacuum"));
345
+
346
+ for (const candidate of candidates) {
347
+ if (!candidate) {
348
+ continue;
349
+ }
350
+
351
+ try {
352
+ const resolved = path.resolve(candidate);
353
+ fs.mkdirSync(resolved, { recursive: true });
354
+ fs.accessSync(resolved, fs.constants.W_OK);
355
+ this.persistBasePath = resolved;
356
+ return this.persistBasePath;
357
+ } catch (error) {
358
+ this.log.debug(
359
+ `Persist path candidate '${candidate}' is not writable: ${error.message}`
360
+ );
361
+ }
362
+ }
363
+
364
+ return this.forceTemporaryPersistPath();
365
+ }
366
+
367
+ forceTemporaryPersistPath() {
368
+ const emergencyPath = path.join(os.tmpdir(), "homebridge-roborock-vacuum");
369
+ fs.mkdirSync(emergencyPath, { recursive: true });
370
+ this.persistBasePath = emergencyPath;
371
+ this.log.warn(`Using emergency temporary persist path '${emergencyPath}'.`);
372
+ return this.persistBasePath;
373
+ }
374
+
375
+ getLegacyPersistPath(id) {
376
+ if (this.config.storagePath) {
377
+ return path.join(this.config.storagePath, `roborock.${id}`);
378
+ }
379
+
380
+ return path.resolve(__dirname, `./data/${id}`);
381
+ }
382
+
383
+ tryMigrateLegacyStateFile(id, state, legacyPath, persistPath) {
384
+ if (!state || !legacyPath || !persistPath || legacyPath === persistPath) {
385
+ return;
386
+ }
387
+
388
+ try {
389
+ fs.mkdirSync(path.dirname(persistPath), { recursive: true });
390
+ fs.writeFileSync(persistPath, JSON.stringify(state, null, 2, "utf8"));
391
+ this.log.info(
392
+ `Migrated legacy '${id}' state file from '${legacyPath}' to '${persistPath}'.`
393
+ );
394
+ } catch (error) {
395
+ this.log.debug(
396
+ `Failed to migrate legacy '${id}' state file: ${error.message}`
397
+ );
398
+ }
399
+ }
400
+
401
+ parseSkipDevices(value) {
402
+ if (!value) {
403
+ return [];
404
+ }
405
+ if (Array.isArray(value)) {
406
+ return value.map((entry) => `${entry}`.trim()).filter((entry) => entry);
407
+ }
408
+ if (typeof value === "string") {
409
+ return value
410
+ .split(",")
411
+ .map((entry) => entry.trim())
412
+ .filter((entry) => entry);
413
+ }
414
+ return [];
415
+ }
416
+
417
+ shouldSkipDevice(device, ignoredSet) {
418
+ if (!device || !ignoredSet) {
419
+ return false;
420
+ }
421
+
422
+ return [device.sn, device.duid]
423
+ .filter((value) => value !== undefined && value !== null)
424
+ .some((value) => ignoredSet.has(`${value}`.trim()));
425
+ }
426
+
427
+ getIgnoredDeviceSet() {
428
+ // Cache on the raw config values; "|| []" fallbacks would mint a fresh
429
+ // array per call and defeat identity-based invalidation.
430
+ const rawIgnored = this.config.ignoredDevices;
431
+ const rawSkip = this.config.skipDevices;
432
+
433
+ if (
434
+ this._ignoredSetCache &&
435
+ this._ignoredSetCacheDeps &&
436
+ this._ignoredSetCacheDeps.rawIgnored === rawIgnored &&
437
+ this._ignoredSetCacheDeps.rawSkip === rawSkip
438
+ ) {
439
+ return this._ignoredSetCache;
440
+ }
441
+
442
+ const ignoredSet = new Set([
443
+ ...(rawIgnored || []),
444
+ ...this.parseSkipDevices(rawSkip),
445
+ ]);
446
+ this._ignoredSetCache = ignoredSet;
447
+ this._ignoredSetCacheDeps = { rawIgnored, rawSkip };
448
+ return ignoredSet;
449
+ }
450
+
451
+ normalizeArray(value) {
452
+ return Array.isArray(value) ? value : [];
453
+ }
454
+
455
+ getStoredHomeData() {
456
+ const homedata = this.getStateAsync("HomeData");
457
+
458
+ if (homedata && typeof homedata.val == "string") {
459
+ // HomeData is tens of kilobytes and this getter sits in hot paths
460
+ // (every Matter attribute read and cluster build resolves device data
461
+ // through it). Parse once per distinct payload instead of on every
462
+ // call; callers never mutate the returned object.
463
+ if (
464
+ homedata.val === this._homeDataParseKey &&
465
+ this._homeDataParsed !== undefined
466
+ ) {
467
+ return this._homeDataParsed;
468
+ }
469
+
470
+ const parsed = JSON.parse(homedata.val);
471
+ this._homeDataParseKey = homedata.val;
472
+ this._homeDataParsed = parsed;
473
+ return parsed;
474
+ }
475
+
476
+ return null;
477
+ }
478
+
479
+ getAllHomeDevices(homedata) {
480
+ const homeDataSource = homedata || this.getStoredHomeData();
481
+ if (!homeDataSource) {
482
+ return [];
483
+ }
484
+
485
+ // Enforce the skip list at the source so every consumer — accessory
486
+ // discovery (HomeKit and Matter), read paths, local-key refresh — sees a
487
+ // consistent device set. Previously only the login-time runtime list was
488
+ // filtered, so skipped robots still had accessories published for them
489
+ // with no runtime behind them. The plugin UI reads the HomeData file
490
+ // directly, so skipped robots remain visible there for re-enabling.
491
+ const ignoredSet = this.getIgnoredDeviceSet();
492
+ return this.normalizeArray(homeDataSource.devices)
493
+ .concat(this.normalizeArray(homeDataSource.receivedDevices))
494
+ .filter((device) => !this.shouldSkipDevice(device, ignoredSet));
495
+ }
496
+
497
+ getManagedHomeDevices(homedata, ignoredSet = this.getIgnoredDeviceSet()) {
498
+ return this.getAllHomeDevices(homedata).filter((device) => {
499
+ return (
500
+ device && device.duid && !this.shouldSkipDevice(device, ignoredSet)
501
+ );
502
+ });
503
+ }
504
+
505
+ getLocalKeyDevices(homedata, ignoredSet = this.getIgnoredDeviceSet()) {
506
+ return this.getManagedHomeDevices(homedata, ignoredSet).filter((device) => {
507
+ if (!device || !device.duid || !device.localKey) {
508
+ return false;
509
+ }
510
+
511
+ if (device.sn && ignoredSet.has(device.sn)) {
512
+ return false;
513
+ }
514
+
515
+ return true;
516
+ });
517
+ }
518
+
519
+ async refreshLocalKeysFromHomeData(
520
+ homedata,
521
+ ignoredSet = this.getIgnoredDeviceSet()
522
+ ) {
523
+ const localKeyDevices = this.getLocalKeyDevices(homedata, ignoredSet);
524
+ const previousKeys =
525
+ this.localKeys instanceof Map ? this.localKeys : new Map();
526
+ const nextKeys = new Map(
527
+ localKeyDevices.map((device) => [device.duid, device.localKey])
528
+ );
529
+
530
+ this.localKeys = nextKeys;
531
+
532
+ for (const device of localKeyDevices) {
533
+ const previousKey = previousKeys.get(device.duid);
534
+ if (!previousKey || previousKey === device.localKey) {
535
+ continue;
536
+ }
537
+
538
+ this.log.debug(
539
+ `Roborock local key changed for ${device.name || device.duid}; resetting LAN TCP connection so local commands use the fresh credentials.`
540
+ );
541
+ if (typeof this.localConnector.resetClient == "function") {
542
+ await this.localConnector.resetClient(device.duid, "local-key-changed");
543
+ }
544
+
545
+ const localIp = this.localDevices?.[device.duid];
546
+ if (!this.isCloudOnlyModeEnabled() && localIp) {
547
+ await this.localConnector.createClient(device.duid, localIp);
548
+ }
549
+ }
550
+
551
+ for (const duid of previousKeys.keys()) {
552
+ if (nextKeys.has(duid)) {
553
+ continue;
554
+ }
555
+
556
+ if (typeof this.localConnector.resetClient == "function") {
557
+ await this.localConnector.resetClient(duid, "missing-local-key");
558
+ }
559
+ }
560
+
561
+ return localKeyDevices;
562
+ }
563
+
564
+ updateRoomMappingCache(duid, mapId, mappedRooms) {
565
+ if (!duid) {
566
+ return;
567
+ }
568
+
569
+ const normalizedMapId = Number(mapId);
570
+ const roomMapId = Number.isFinite(normalizedMapId) ? normalizedMapId : null;
571
+ const entry = this.ensureRoomMappingEntry(duid);
572
+ const rooms = [];
573
+ const seenSegments = new Set();
574
+
575
+ for (const mappedRoom of this.normalizeArray(mappedRooms)) {
576
+ if (!Array.isArray(mappedRoom) || mappedRoom.length < 2) {
577
+ continue;
578
+ }
579
+
580
+ const segmentId = Number(mappedRoom[0]);
581
+ const roomId = Number(mappedRoom[1]);
582
+ if (!Number.isInteger(segmentId) || segmentId < 0) {
583
+ continue;
584
+ }
585
+ if (seenSegments.has(segmentId)) {
586
+ continue;
587
+ }
588
+
589
+ seenSegments.add(segmentId);
590
+ rooms.push({
591
+ segmentId,
592
+ roomId: Number.isFinite(roomId) ? roomId : mappedRoom[1],
593
+ mapId: roomMapId,
594
+ name: this.roomIDs[mappedRoom[1]] || `Room ${mappedRoom[1]}`,
595
+ });
596
+ }
597
+
598
+ entry.mapId = roomMapId;
599
+ entry.currentMapId = roomMapId;
600
+ entry.roomsByMap[this.getRoomMappingMapKey(roomMapId)] = rooms;
601
+ entry.rooms = this.getFlattenedRoomMappings(entry);
602
+ entry.updatedAt = new Date().toISOString();
603
+ this.ensureMapListEntry(entry, roomMapId);
604
+ this.persistRoomMappings();
605
+
606
+ if (this.deviceNotify) {
607
+ this.deviceNotify("RoomMapping", {
608
+ duid,
609
+ mapId: entry.mapId,
610
+ rooms,
611
+ });
612
+ }
613
+ }
614
+
615
+ updateMapListCache(duid, mapInfo) {
616
+ if (!duid) {
617
+ return;
618
+ }
619
+
620
+ const maps = [];
621
+ const seenMapIds = new Set();
622
+ const mapEntries = Array.isArray(mapInfo)
623
+ ? mapInfo
624
+ : mapInfo && typeof mapInfo === "object"
625
+ ? Object.values(mapInfo)
626
+ : [];
627
+
628
+ for (const map of mapEntries) {
629
+ const mapRecord = map && typeof map === "object" ? map : null;
630
+ const mapId = Number(mapRecord?.mapFlag);
631
+ if (!Number.isInteger(mapId) || mapId < 0 || seenMapIds.has(mapId)) {
632
+ continue;
633
+ }
634
+
635
+ const normalizedName =
636
+ typeof mapRecord.name === "string" ? mapRecord.name.trim() : "";
637
+ seenMapIds.add(mapId);
638
+ maps.push({
639
+ mapId,
640
+ name: normalizedName || `Roborock Map ${mapId}`,
641
+ });
642
+ }
643
+
644
+ const entry = this.ensureRoomMappingEntry(duid);
645
+ if (maps.length > 0) {
646
+ entry.maps = maps;
647
+ }
648
+ entry.updatedAt = new Date().toISOString();
649
+ this.ensureMapListEntry(entry, entry.currentMapId ?? entry.mapId ?? null);
650
+ this.persistRoomMappings();
651
+
652
+ if (this.deviceNotify) {
653
+ this.deviceNotify("RoomMapping", {
654
+ duid,
655
+ mapId: entry.mapId ?? null,
656
+ rooms: this.getFlattenedRoomMappings(entry),
657
+ });
658
+ }
659
+ }
660
+
661
+ getRoomMappingsForDevice(duid) {
662
+ if (this.getVacuumDeviceInfo(duid, "pv") === b01Q7Adapter.B01_PROTOCOL_VERSION) {
663
+ return this.getB01RoomCache(duid).map((room) => ({
664
+ segmentId: room.roomId,
665
+ mapId: 0,
666
+ name: room.roomName || `Room ${room.roomId}`,
667
+ }));
668
+ }
669
+
670
+ const mapping = this.roomMappings[duid];
671
+ if (!mapping) {
672
+ return [];
673
+ }
674
+
675
+ return this.getFlattenedRoomMappings(mapping).map((room) => ({ ...room }));
676
+ }
677
+
678
+ getRoomMappingsForMap(duid, mapId) {
679
+ const mapping = this.roomMappings[duid];
680
+ if (!mapping || !mapping.roomsByMap) {
681
+ return [];
682
+ }
683
+
684
+ const rooms = mapping.roomsByMap[this.getRoomMappingMapKey(mapId)];
685
+ return this.normalizeArray(rooms).map((room) => ({ ...room }));
686
+ }
687
+
688
+ getMapListForDevice(duid) {
689
+ const mapping = this.roomMappings[duid];
690
+ if (!mapping || !Array.isArray(mapping.maps)) {
691
+ return [];
692
+ }
693
+
694
+ return mapping.maps.map((map) => ({ ...map }));
695
+ }
696
+
697
+ getCurrentMapIdForDevice(duid) {
698
+ // B01/Q7 rooms are always fetched from the robot's CURRENT map (the
699
+ // `cur` flag in service.get_map_list), and the cache exposes them under
700
+ // the canonical mapId 0. Reporting 0 here keeps the Matter room-clean
701
+ // flow from attempting a map switch (load_multi_map has no Q7
702
+ // equivalent) before sending the segment command.
703
+ if (
704
+ this.getVacuumDeviceInfo(duid, "pv") === b01Q7Adapter.B01_PROTOCOL_VERSION
705
+ ) {
706
+ return 0;
707
+ }
708
+
709
+ const mapping = this.roomMappings[duid];
710
+ if (!mapping) {
711
+ return null;
712
+ }
713
+
714
+ return mapping.currentMapId ?? mapping.mapId ?? null;
715
+ }
716
+
717
+ getPersistedRoomMappings() {
718
+ const cached = this.getStateAsync("RoomMappings");
719
+ const value = cached?.val;
720
+
721
+ if (!value) {
722
+ return {};
723
+ }
724
+
725
+ try {
726
+ return typeof value === "string" ? JSON.parse(value) : value;
727
+ } catch (error) {
728
+ this.log.debug(`Failed to parse persisted room mappings: ${error}`);
729
+ return {};
730
+ }
731
+ }
732
+
733
+ ensureRoomMappingEntry(duid) {
734
+ const existing = this.roomMappings[duid] || {};
735
+ if (!existing.roomsByMap) {
736
+ existing.roomsByMap = {};
737
+ }
738
+ if (!Array.isArray(existing.maps)) {
739
+ existing.maps = [];
740
+ }
741
+
742
+ this.roomMappings[duid] = existing;
743
+ return existing;
744
+ }
745
+
746
+ ensureMapListEntry(entry, mapId) {
747
+ if (mapId === null || mapId === undefined) {
748
+ return;
749
+ }
750
+
751
+ if (!Array.isArray(entry.maps)) {
752
+ entry.maps = [];
753
+ }
754
+
755
+ if (!entry.maps.some((map) => map.mapId === mapId)) {
756
+ entry.maps.push({
757
+ mapId,
758
+ name: `Roborock Map ${mapId}`,
759
+ });
760
+ }
761
+ }
762
+
763
+ getFlattenedRoomMappings(mapping) {
764
+ if (mapping.roomsByMap && typeof mapping.roomsByMap === "object") {
765
+ return Object.values(mapping.roomsByMap).flatMap((rooms) =>
766
+ this.normalizeArray(rooms)
767
+ );
768
+ }
769
+
770
+ return this.normalizeArray(mapping.rooms);
771
+ }
772
+
773
+ getRoomMappingMapKey(mapId) {
774
+ return mapId === null || mapId === undefined ? "none" : String(mapId);
775
+ }
776
+
777
+ persistRoomMappings() {
778
+ void this.setStateAsync("RoomMappings", {
779
+ val: JSON.stringify(this.roomMappings),
780
+ ack: true,
781
+ });
782
+ }
783
+
784
+ getTransportDiagnostics() {
785
+ const diagnostics = this.getStateAsync("TransportDiagnostics");
786
+ if (diagnostics && typeof diagnostics.val == "string") {
787
+ try {
788
+ return JSON.parse(diagnostics.val);
789
+ } catch (error) {
790
+ this.log.debug(
791
+ `Failed to parse transport diagnostics state: ${error.message}`
792
+ );
793
+ }
794
+ }
795
+
796
+ return {};
797
+ }
798
+
799
+ getRoborockDiagnostics() {
800
+ const diagnostics = this.getStateAsync("RoborockDiagnostics");
801
+ if (diagnostics && typeof diagnostics.val == "string") {
802
+ try {
803
+ return JSON.parse(diagnostics.val);
804
+ } catch (error) {
805
+ this.log.debug(
806
+ `Failed to parse Roborock diagnostics state: ${error.message}`
807
+ );
808
+ }
809
+ }
810
+
811
+ return {};
812
+ }
813
+
814
+ async updateRoborockDiagnostics(duid, key, payload) {
815
+ if (!duid || !key) {
816
+ return;
817
+ }
818
+
819
+ const diagnostics = this.getRoborockDiagnostics();
820
+ const currentEntry =
821
+ diagnostics[duid] && typeof diagnostics[duid] === "object"
822
+ ? diagnostics[duid]
823
+ : {};
824
+
825
+ diagnostics[duid] = {
826
+ ...currentEntry,
827
+ [key]: this.compactDiagnosticPayload(payload),
828
+ updatedAt: new Date().toISOString(),
829
+ };
830
+
831
+ await this.setStateAsync("RoborockDiagnostics", {
832
+ val: JSON.stringify(diagnostics),
833
+ ack: true,
834
+ });
835
+ }
836
+
837
+ recordRoborockDiagnosticMessage(source, message) {
838
+ if (source !== "CloudMessage" && source !== "LocalMessage") {
839
+ return;
840
+ }
841
+
842
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
843
+ return;
844
+ }
845
+
846
+ const { duid, payload } = message;
847
+ if (!duid || payload === undefined) {
848
+ return;
849
+ }
850
+
851
+ const key =
852
+ source === "CloudMessage" ? "lastCloudMessage" : "lastLocalMessage";
853
+ void this.updateRoborockDiagnostics(String(duid), key, {
854
+ source,
855
+ receivedAt: new Date().toISOString(),
856
+ payload,
857
+ });
858
+ }
859
+
860
+ compactDiagnosticPayload(value, depth = 0) {
861
+ if (value === null || value === undefined) {
862
+ return value;
863
+ }
864
+
865
+ if (typeof value === "string") {
866
+ return value.length > 500 ? `${value.slice(0, 500)}...` : value;
867
+ }
868
+
869
+ if (typeof value !== "object") {
870
+ return value;
871
+ }
872
+
873
+ if (depth >= 3) {
874
+ return Array.isArray(value) ? `[array:${value.length}]` : "[object]";
875
+ }
876
+
877
+ if (Array.isArray(value)) {
878
+ const compactArray = value
879
+ .slice(0, 8)
880
+ .map((entry) => this.compactDiagnosticPayload(entry, depth + 1));
881
+ if (value.length > compactArray.length) {
882
+ compactArray.push(`[truncated:${value.length - compactArray.length}]`);
883
+ }
884
+ return compactArray;
885
+ }
886
+
887
+ const compactObject = {};
888
+ const entries = Object.entries(value);
889
+ for (const [key, entryValue] of entries.slice(0, 30)) {
890
+ if (this.isSensitiveDiagnosticKey(key)) {
891
+ compactObject[key] = "[redacted]";
892
+ continue;
893
+ }
894
+
895
+ compactObject[key] = this.compactDiagnosticPayload(entryValue, depth + 1);
896
+ }
897
+
898
+ if (entries.length > Object.keys(compactObject).length) {
899
+ compactObject.__truncatedKeys =
900
+ entries.length - Object.keys(compactObject).length;
901
+ }
902
+
903
+ return compactObject;
904
+ }
905
+
906
+ isSensitiveDiagnosticKey(key) {
907
+ return /token|localkey|local_key|password|secret|rriot|key/i.test(
908
+ String(key)
909
+ );
910
+ }
911
+
912
+ async updateTransportDiagnostics(duid, patch) {
913
+ if (!duid || !patch || typeof patch !== "object") {
914
+ return;
915
+ }
916
+
917
+ const diagnostics = this.getTransportDiagnostics();
918
+ const currentEntry =
919
+ diagnostics[duid] && typeof diagnostics[duid] === "object"
920
+ ? diagnostics[duid]
921
+ : {};
922
+
923
+ diagnostics[duid] = {
924
+ ...currentEntry,
925
+ ...patch,
926
+ updatedAt: new Date().toISOString(),
927
+ };
928
+
929
+ await this.setStateAsync("TransportDiagnostics", {
930
+ val: JSON.stringify(diagnostics),
931
+ ack: true,
932
+ });
933
+
934
+ this.logTransportDiagnosticsChange(
935
+ duid,
936
+ currentEntry,
937
+ diagnostics[duid],
938
+ patch
939
+ );
940
+ }
941
+
942
+ logTransportDiagnosticsChange(duid, previous, current, patch) {
943
+ const message = this.buildTransportDiagnosticsLogMessage(
944
+ duid,
945
+ previous || {},
946
+ current || {},
947
+ patch || {}
948
+ );
949
+
950
+ if (message) {
951
+ this.log.debug(message);
952
+ }
953
+ }
954
+
955
+ buildTransportDiagnosticsLogMessage(duid, previous, current, patch) {
956
+ const deviceLabel = this.formatDeviceForTransportLog(duid);
957
+ const method =
958
+ current.lastCommandMethod || patch.lastCommandMethod || "unknown method";
959
+
960
+ const changed = (field) =>
961
+ Object.prototype.hasOwnProperty.call(patch, field) &&
962
+ previous[field] !== current[field];
963
+
964
+ if (changed("tcpConnectionState")) {
965
+ return this.describeTcpTransportChange(deviceLabel, current);
966
+ }
967
+
968
+ if (changed("isRemote") || changed("remoteReason")) {
969
+ return this.describeRemoteTransportChange(deviceLabel, current);
970
+ }
971
+
972
+ if (changed("online")) {
973
+ return current.online
974
+ ? `Roborock reports ${deviceLabel} is online again; local transport can resume when TCP is connected.`
975
+ : `Roborock reports ${deviceLabel} is offline; commands will wait or fall back to cloud when possible.`;
976
+ }
977
+
978
+ if (changed("localIp")) {
979
+ return `Discovered local IP ${this.formatLocalIpForLog(current.localIp)} for ${deviceLabel}; LAN TCP connection can be attempted.`;
980
+ }
981
+
982
+ if (changed("localDiscoveryState")) {
983
+ return this.describeLocalDiscoveryChange(deviceLabel, current);
984
+ }
985
+
986
+ if (changed("lastTransport") || changed("lastTransportReason")) {
987
+ return this.describeTransportRouteChange(
988
+ deviceLabel,
989
+ previous,
990
+ current,
991
+ method
992
+ );
993
+ }
994
+
995
+ return null;
996
+ }
997
+
998
+ describeTcpTransportChange(deviceLabel, current) {
999
+ const ip = this.formatLocalIpForLog(current.localIp);
1000
+ const reason = this.describeTransportReason(current.lastTransportReason);
1001
+
1002
+ switch (current.tcpConnectionState) {
1003
+ case "connecting":
1004
+ return `Opening local LAN TCP connection to ${deviceLabel}${ip ? ` at ${ip}` : ""}.`;
1005
+ case "connected":
1006
+ return `Local LAN TCP connected to ${deviceLabel}${ip ? ` at ${ip}` : ""}; commands can use local transport.`;
1007
+ case "disabled":
1008
+ return `Local LAN TCP disabled for ${deviceLabel}; using Roborock cloud transport because ${reason}.`;
1009
+ case "connect-failed":
1010
+ return `Local LAN TCP connection failed for ${deviceLabel}${ip ? ` at ${ip}` : ""}; ${reason}. Cloud transport will be used when available.`;
1011
+ case "disconnected":
1012
+ return `Local LAN TCP disconnected for ${deviceLabel}; cloud fallback will be used until local reconnects.`;
1013
+ case "error":
1014
+ return `Local LAN TCP error for ${deviceLabel}; ${reason}. Cloud fallback will be used when available.`;
1015
+ default:
1016
+ return `Local LAN TCP state for ${deviceLabel} changed to ${current.tcpConnectionState || "unknown"}.`;
1017
+ }
1018
+ }
1019
+
1020
+ describeRemoteTransportChange(deviceLabel, current) {
1021
+ if (current.isRemote) {
1022
+ const reason = current.remoteReason || "remote-device";
1023
+ return `Using Roborock cloud transport for ${deviceLabel} because ${this.describeTransportReason(reason)}.`;
1024
+ }
1025
+
1026
+ return `${deviceLabel} is no longer marked remote; local transport may be used when credentials and TCP are available.`;
1027
+ }
1028
+
1029
+ describeLocalDiscoveryChange(deviceLabel, current) {
1030
+ if (current.localDiscoveryState === "disabled") {
1031
+ return `Local discovery disabled for ${deviceLabel}; using Roborock cloud transport because ${this.describeTransportReason(current.lastTransportReason)}.`;
1032
+ }
1033
+
1034
+ if (current.localDiscoveryState === "not-discovered") {
1035
+ return `No local IP is cached for ${deviceLabel}; the plugin will use cloud transport until discovery succeeds.`;
1036
+ }
1037
+
1038
+ return `Local discovery for ${deviceLabel} changed to ${current.localDiscoveryState || "unknown"}.`;
1039
+ }
1040
+
1041
+ describeTransportRouteChange(deviceLabel, previous, current, method) {
1042
+ const reason = this.describeTransportReason(current.lastTransportReason);
1043
+
1044
+ if (current.lastTransport === "local") {
1045
+ if (previous.lastTransport === "cloud") {
1046
+ return `Local transport recovered for ${deviceLabel}; using LAN TCP for ${method} because ${reason}.`;
1047
+ }
1048
+
1049
+ return `Using local LAN transport for ${deviceLabel} (${method}) because ${reason}.`;
1050
+ }
1051
+
1052
+ if (current.lastTransport === "cloud") {
1053
+ if (this.isCloudOnlyTransportReason(current.lastTransportReason)) {
1054
+ return `Using Roborock cloud transport for ${deviceLabel} (${method}) because ${reason}.`;
1055
+ }
1056
+
1057
+ if (previous.lastTransport === "local") {
1058
+ return `Falling back from local LAN to Roborock cloud for ${deviceLabel} (${method}) because ${reason}.`;
1059
+ }
1060
+
1061
+ return `Using Roborock cloud transport for ${deviceLabel} (${method}) because ${reason}.`;
1062
+ }
1063
+
1064
+ if (current.lastTransport === "local-pending") {
1065
+ return `Preparing local LAN transport for ${deviceLabel}; waiting for TCP connection.`;
1066
+ }
1067
+
1068
+ return null;
1069
+ }
1070
+
1071
+ isCloudOnlyTransportReason(reason) {
1072
+ return [
1073
+ "cloud-only-mode",
1074
+ "cloud-only-mqtt-unavailable",
1075
+ "network-info-cloud-only",
1076
+ "secure-command",
1077
+ "photo-command",
1078
+ "preferred-cloud-command",
1079
+ ].includes(String(reason));
1080
+ }
1081
+
1082
+ describeTransportReason(reason) {
1083
+ const reasons = {
1084
+ "cloud-only-mode": "cloud-only mode is enabled",
1085
+ "cloud-only-mqtt-unavailable":
1086
+ "cloud-only mode is enabled but Roborock cloud MQTT is unavailable",
1087
+ "cloud-request": "cloud transport was selected for this command",
1088
+ "device-offline": "Roborock currently reports the vacuum offline",
1089
+ "device-offline-during-connect":
1090
+ "Roborock reported the vacuum offline while opening the local TCP connection",
1091
+ "local-request": "an active LAN TCP connection is available",
1092
+ "local-socket-unavailable":
1093
+ "the local TCP socket was unavailable when the command was requested",
1094
+ "local-unavailable-fallback":
1095
+ "the local TCP socket was not connected when the command was requested",
1096
+ "missing-local-ip": "no local IP address is cached for this vacuum",
1097
+ "missing-local-key": "no local credential is cached for this vacuum",
1098
+ "mqtt-unavailable": "the Roborock cloud MQTT connection is unavailable",
1099
+ "network-info-cloud-only":
1100
+ "Roborock network information must be fetched through the cloud",
1101
+ "photo-command": "photo requests require Roborock cloud transport",
1102
+ "preferred-cloud-command":
1103
+ "Matter commands are configured to prefer Roborock cloud transport",
1104
+ "received-device":
1105
+ "the vacuum is shared into this account as a received device",
1106
+ "remote-device": "the vacuum is marked remote",
1107
+ "secure-command": "this secure command requires Roborock cloud transport",
1108
+ "tcp-connected": "the local TCP socket connected successfully",
1109
+ "tcp-connect-failed": "opening the local TCP socket failed",
1110
+ "tcp-disconnected": "the local TCP socket disconnected",
1111
+ "udp-broadcast-discovery": "UDP broadcast discovery found the vacuum",
1112
+ "marked-remote-after-connect-failure":
1113
+ "local TCP connection failed and the vacuum was marked remote",
1114
+ };
1115
+
1116
+ if (!reason) {
1117
+ return "no transport reason was recorded";
1118
+ }
1119
+
1120
+ const normalizedReason = String(reason);
1121
+
1122
+ if (normalizedReason.startsWith("tcp-error:")) {
1123
+ return `the local TCP socket reported ${normalizedReason.replace("tcp-error:", "").trim()}`;
1124
+ }
1125
+
1126
+ return reasons[normalizedReason] || normalizedReason;
1127
+ }
1128
+
1129
+ formatDeviceForTransportLog(duid) {
1130
+ const device = this.getAllHomeDevices().find(
1131
+ (entry) => entry && entry.duid === duid
1132
+ );
1133
+ const name = device?.name || "Roborock vacuum";
1134
+ return `${name} (${this.maskIdentifierForLog(duid)})`;
1135
+ }
1136
+
1137
+ maskIdentifierForLog(value) {
1138
+ if (!value) {
1139
+ return "unknown";
1140
+ }
1141
+
1142
+ const normalized = String(value);
1143
+ if (normalized.length <= 8) {
1144
+ return "[redacted]";
1145
+ }
1146
+
1147
+ return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`;
1148
+ }
1149
+
1150
+ formatLocalIpForLog(value) {
1151
+ if (!value) {
1152
+ return "";
1153
+ }
1154
+
1155
+ const parts = String(value).split(".");
1156
+ if (parts.length === 4) {
1157
+ return `${parts.slice(0, 3).join(".")}.x`;
1158
+ }
1159
+
1160
+ return "local IP present";
1161
+ }
1162
+
1163
+ getKnownProducts(homedata) {
1164
+ const homeDataSource = homedata || this.getStoredHomeData();
1165
+ return this.normalizeArray(homeDataSource?.products || this.products);
1166
+ }
1167
+
1168
+ getDeviceAttribute(device, attribute) {
1169
+ if (!device) {
1170
+ return null;
1171
+ }
1172
+
1173
+ const candidateKeys =
1174
+ attribute === "model"
1175
+ ? ["model", "productModel", "productCode", "modelId"]
1176
+ : [attribute];
1177
+
1178
+ for (const key of candidateKeys) {
1179
+ const value = device[key];
1180
+ if (typeof value === "string" && value.trim()) {
1181
+ return value.trim();
1182
+ }
1183
+ if (value !== undefined && value !== null && value !== "") {
1184
+ return value;
1185
+ }
1186
+ }
1187
+
1188
+ return null;
1189
+ }
1190
+
1191
+ /**
1192
+ * Is called when databases are connected and adapter received configuration.
1193
+ */
1194
+ async startService(callback) {
1195
+ this.log.info(
1196
+ `Starting adapter. This might take a few minutes depending on your setup. Please wait.`
1197
+ );
1198
+ this.translations = require(
1199
+ `./i18n/${this.language || "en"}/translations.json`
1200
+ );
1201
+
1202
+ // create new clientID if it doesn't exist yet
1203
+ let clientID = "";
1204
+ try {
1205
+ const storedClientID = await this.getStateAsync("clientID");
1206
+ if (storedClientID) {
1207
+ clientID = storedClientID.val?.toString() ?? "";
1208
+ } else {
1209
+ clientID = crypto.randomUUID();
1210
+ await this.setStateAsync("clientID", { val: clientID, ack: true });
1211
+ }
1212
+ } catch (error) {
1213
+ this.log.error(
1214
+ `Error while retrieving or setting clientID: ${error.message}`
1215
+ );
1216
+ }
1217
+
1218
+ if (!this.config.username) {
1219
+ this.log.error("Email is missing!");
1220
+ return;
1221
+ }
1222
+ if (!this.config.password && !this.isValidUserData(this.userData)) {
1223
+ this.log.error("Password or valid token is missing!");
1224
+ return;
1225
+ }
1226
+
1227
+ this.instance = clientID;
1228
+
1229
+ // Initialize the login API (which is needed to get access to the real API).
1230
+ this.loginApi = roborockAuth.createLoginApi({
1231
+ baseURL: this.baseURL,
1232
+ username: this.config.username,
1233
+ clientID,
1234
+ language: this.language,
1235
+ });
1236
+ await this.setStateAsync("info.connection", { val: true, ack: true });
1237
+ // api/v1/getUrlByEmail(email = ...)
1238
+
1239
+ const userdata = await this.getUserData(this.loginApi);
1240
+ if (!userdata) {
1241
+ this.log.error(
1242
+ "Login failed or requires 2FA. Please complete authentication in the Config UI."
1243
+ );
1244
+ await this.setStateAsync("info.connection", { val: false, ack: true });
1245
+ return;
1246
+ }
1247
+
1248
+ try {
1249
+ this.loginApi.defaults.headers.common["Authorization"] = userdata.token;
1250
+ } catch (error) {
1251
+ this.log.error(
1252
+ "Failed to login. Most likely wrong token! Deleting HomeData and UserData. Try again! " +
1253
+ error
1254
+ );
1255
+
1256
+ this.deleteStateAsync("HomeData");
1257
+ this.deleteStateAsync("UserData");
1258
+ }
1259
+ const rriot = userdata.rriot;
1260
+
1261
+ // Initialize the real API.
1262
+ this.api = axios.create({
1263
+ baseURL: rriot.r.a,
1264
+ });
1265
+ this.api.interceptors.request.use((config) => {
1266
+ try {
1267
+ const timestamp = Math.floor(Date.now() / 1000);
1268
+ const nonce = crypto
1269
+ .randomBytes(6)
1270
+ .toString("base64")
1271
+ .substring(0, 6)
1272
+ .replace("+", "X")
1273
+ .replace("/", "Y");
1274
+ let url;
1275
+ if (this.api) {
1276
+ url = new URL(this.api.getUri(config));
1277
+ const prestr = [
1278
+ rriot.u,
1279
+ rriot.s,
1280
+ nonce,
1281
+ timestamp,
1282
+ roborockCrypto.md5hex(url.pathname),
1283
+ /*queryparams*/ "",
1284
+ /*body*/ "",
1285
+ ].join(":");
1286
+ const mac = crypto
1287
+ .createHmac("sha256", rriot.h)
1288
+ .update(prestr)
1289
+ .digest("base64");
1290
+
1291
+ config.headers["Authorization"] =
1292
+ `Hawk id="${rriot.u}", s="${rriot.s}", ts="${timestamp}", nonce="${nonce}", mac="${mac}"`;
1293
+ }
1294
+ } catch (error) {
1295
+ this.log.error("Failed to initialize API. Error: " + error);
1296
+ }
1297
+ return config;
1298
+ });
1299
+
1300
+ // Get home details.
1301
+ try {
1302
+ const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
1303
+ if (homeDetail) {
1304
+ const homeId = homeDetail.data.data.rrHomeId;
1305
+
1306
+ if (this.api) {
1307
+ const homedata = await this.api.get(`v2/user/homes/${homeId}`);
1308
+ const homedataResult = homedata.data.result;
1309
+
1310
+ const scene = await this.api.get(`user/scene/home/${homeId}`);
1311
+
1312
+ await this.setStateAsync("HomeData", {
1313
+ val: JSON.stringify(homedataResult),
1314
+ ack: true,
1315
+ });
1316
+
1317
+ // Skip devices matching either their serial number or Roborock DUID.
1318
+ const ignoredSet = this.getIgnoredDeviceSet();
1319
+ // create devices and set states
1320
+ this.products = homedataResult.products;
1321
+ this.devices = homedataResult.devices || [];
1322
+ this.devices = this.devices.filter(
1323
+ (device) => !this.shouldSkipDevice(device, ignoredSet)
1324
+ );
1325
+
1326
+ const managedDevicesForDiagnostics = this.getManagedHomeDevices(
1327
+ homedataResult,
1328
+ ignoredSet
1329
+ );
1330
+ const localKeyDevices = await this.refreshLocalKeysFromHomeData(
1331
+ homedataResult,
1332
+ ignoredSet
1333
+ );
1334
+
1335
+ if (this.isCloudOnlyModeEnabled()) {
1336
+ this.log.info(
1337
+ "Roborock cloud-only mode is enabled; local LAN discovery and TCP connections will be skipped."
1338
+ );
1339
+
1340
+ for (const device of managedDevicesForDiagnostics) {
1341
+ await this.updateTransportDiagnostics(device.duid, {
1342
+ lastTransport: "cloud",
1343
+ lastTransportReason: "cloud-only-mode",
1344
+ localIp: null,
1345
+ localDiscoveryState: "disabled",
1346
+ tcpConnectionState: "disabled",
1347
+ });
1348
+ }
1349
+ } else {
1350
+ for (const device of managedDevicesForDiagnostics) {
1351
+ if (!device.localKey) {
1352
+ await this.updateTransportDiagnostics(device.duid, {
1353
+ lastTransport: "cloud",
1354
+ lastTransportReason: "missing-local-key",
1355
+ });
1356
+ }
1357
+ }
1358
+ }
1359
+
1360
+ // this.adapter.log.debug(`initUser test: ${JSON.stringify(Array.from(this.adapter.localKeys.entries()))}`);
1361
+
1362
+ await this.rr_mqtt_connector.initUser(userdata);
1363
+ await this.rr_mqtt_connector.initMQTT_Subscribe();
1364
+ await this.rr_mqtt_connector.initMQTT_Message();
1365
+
1366
+ // store name of each room via ID
1367
+ const rooms = homedataResult.rooms;
1368
+ for (const room in rooms) {
1369
+ const roomID = rooms[room].id;
1370
+ const roomName = rooms[room].name;
1371
+
1372
+ this.roomIDs[roomID] = roomName;
1373
+ }
1374
+ this.log.debug(`RoomIDs debug: ${JSON.stringify(this.roomIDs)}`);
1375
+
1376
+ // Perform a periodic MQTT health check. Reconnect only if needed.
1377
+ this.reconnectIntervall = this.setInterval(async () => {
1378
+ this.log.debug(`Running MQTT health check.`);
1379
+
1380
+ await this.rr_mqtt_connector.ensureConnected();
1381
+ }, 3600 * 1000);
1382
+
1383
+ this.homedataInterval = this.setInterval(
1384
+ this.updateHomeData.bind(this),
1385
+ this.updateInterval * 1000,
1386
+ homeId
1387
+ );
1388
+ await this.updateHomeData(homeId);
1389
+
1390
+ const discoveredDevices = this.isCloudOnlyModeEnabled()
1391
+ ? {}
1392
+ : await this.localConnector.getLocalDevices();
1393
+
1394
+ await this.createDevices();
1395
+ await this.getNetworkInfo();
1396
+
1397
+ if (!this.isCloudOnlyModeEnabled()) {
1398
+ // merge udp discovered devices with local devices found via mqtt
1399
+ Object.entries(discoveredDevices).forEach(([duid, ip]) => {
1400
+ if (
1401
+ !Object.prototype.hasOwnProperty.call(this.localDevices, duid)
1402
+ ) {
1403
+ this.localDevices[duid] = ip;
1404
+ }
1405
+ });
1406
+ this.log.debug(
1407
+ `localDevices: ${JSON.stringify(this.localDevices)}`
1408
+ );
1409
+
1410
+ for (const device of localKeyDevices) {
1411
+ if (
1412
+ !Object.prototype.hasOwnProperty.call(
1413
+ this.localDevices,
1414
+ device.duid
1415
+ )
1416
+ ) {
1417
+ await this.updateTransportDiagnostics(device.duid, {
1418
+ localDiscoveryState: "not-discovered",
1419
+ lastTransportReason: "missing-local-ip",
1420
+ });
1421
+ }
1422
+ }
1423
+
1424
+ for (const device in this.localDevices) {
1425
+ const duid = device;
1426
+ const ip = this.localDevices[device];
1427
+
1428
+ await this.updateTransportDiagnostics(duid, {
1429
+ localIp: ip,
1430
+ localDiscoveryState: "discovered",
1431
+ });
1432
+ await this.localConnector.createClient(duid, ip);
1433
+ }
1434
+ }
1435
+
1436
+ await this.initializeDeviceUpdates();
1437
+ this.bInited = true;
1438
+ this.log.info(`Starting adapter finished. Lets go!!!!!!!`);
1439
+ } else {
1440
+ this.log.info(
1441
+ `Most likely failed to login. Deleting UserData to force new login!`
1442
+ );
1443
+ await this.deleteStateAsync(`UserData`);
1444
+ }
1445
+ }
1446
+ } catch (error) {
1447
+ this.log.error("Failed to get home details: " + error.stack);
1448
+ }
1449
+
1450
+ if (callback) {
1451
+ callback();
1452
+ }
1453
+ }
1454
+
1455
+ async stopService() {
1456
+ try {
1457
+ await this.clearTimersAndIntervals();
1458
+ this.bInited = false;
1459
+ } catch (e) {
1460
+ this.catchError(e.stack);
1461
+ }
1462
+ }
1463
+
1464
+ async getUserData(loginApi) {
1465
+ try {
1466
+ if (this.isValidUserData(this.userData)) {
1467
+ this.log.info("Using session from config.");
1468
+ return this.userData;
1469
+ }
1470
+
1471
+ const cachedState = await this.getStateAsync("UserData");
1472
+ if (cachedState && cachedState.val) {
1473
+ try {
1474
+ const cached = JSON.parse(cachedState.val);
1475
+ if (this.isValidUserData(cached)) {
1476
+ this.userData = cached;
1477
+ this.log.info("Using cached session from disk.");
1478
+ return cached;
1479
+ }
1480
+ } catch (error) {
1481
+ this.log.warn("Cached session is invalid and will be ignored.");
1482
+ }
1483
+ }
1484
+
1485
+ if (!this.config.password) {
1486
+ this.log.error("Password is missing and no valid token is available.");
1487
+ return null;
1488
+ }
1489
+
1490
+ const signData = await this.ensureAuthSignature();
1491
+ if (!signData) {
1492
+ throw new Error("Failed to obtain login signature.");
1493
+ }
1494
+
1495
+ const loginResult = await roborockAuth.loginByPassword(loginApi, {
1496
+ email: this.config.username,
1497
+ password: this.config.password,
1498
+ k: signData.k,
1499
+ s: signData.s,
1500
+ });
1501
+
1502
+ if (loginResult && loginResult.code === 200 && loginResult.data) {
1503
+ this.userData = loginResult.data;
1504
+ this.pendingAuth = null;
1505
+ await this.setStateAsync("UserData", {
1506
+ val: JSON.stringify(this.userData),
1507
+ ack: true,
1508
+ });
1509
+ this.authState.twoFactorRequired = false;
1510
+ this.authState.statusMessage = "";
1511
+ return this.userData;
1512
+ }
1513
+
1514
+ if (loginResult && loginResult.code === 2031) {
1515
+ this.authState.twoFactorRequired = true;
1516
+ this.authState.statusMessage = "Two-factor authentication required.";
1517
+ this.log.error(
1518
+ "Two-factor authentication required. Use the Config UI to continue."
1519
+ );
1520
+ return null;
1521
+ }
1522
+
1523
+ throw new Error(`Login failed: ${JSON.stringify(loginResult)}`);
1524
+ } catch (error) {
1525
+ this.log.error(`Error in getUserData: ${error.message}`);
1526
+ await this.deleteStateAsync("HomeData");
1527
+ await this.deleteStateAsync("UserData");
1528
+ throw error;
1529
+ }
1530
+ }
1531
+
1532
+ isValidUserData(userdata) {
1533
+ return userdata && userdata.token && userdata.rriot;
1534
+ }
1535
+
1536
+ async ensureAuthSignature() {
1537
+ if (this.pendingAuth && this.pendingAuth.k && this.pendingAuth.s) {
1538
+ return this.pendingAuth;
1539
+ }
1540
+
1541
+ if (!this.loginApi) {
1542
+ throw new Error("Login API is not initialized.");
1543
+ }
1544
+
1545
+ const s = crypto
1546
+ .randomBytes(12)
1547
+ .toString("base64")
1548
+ .substring(0, 16)
1549
+ .replace(/\+/g, "X")
1550
+ .replace(/\//g, "Y");
1551
+ const signData = await roborockAuth.signRequest(this.loginApi, s);
1552
+ if (!signData || !signData.k) {
1553
+ return null;
1554
+ }
1555
+
1556
+ this.pendingAuth = { k: signData.k, s };
1557
+ return this.pendingAuth;
1558
+ }
1559
+
1560
+ async sendTwoFactorEmail() {
1561
+ if (!this.loginApi) {
1562
+ throw new Error("Login API is not initialized.");
1563
+ }
1564
+
1565
+ try {
1566
+ await roborockAuth.requestEmailCode(this.loginApi, this.config.username);
1567
+ } catch (error) {
1568
+ this.log.error(`2FA email request failed: ${error.message}`);
1569
+ throw error;
1570
+ }
1571
+ this.authState.twoFactorRequired = true;
1572
+ this.authState.statusMessage = "Verification email sent.";
1573
+ return { ok: true };
1574
+ }
1575
+
1576
+ async verifyTwoFactorCode(code) {
1577
+ if (!this.loginApi) {
1578
+ throw new Error("Login API is not initialized.");
1579
+ }
1580
+
1581
+ const signData = await this.ensureAuthSignature();
1582
+ if (!signData) {
1583
+ throw new Error("Missing login signature.");
1584
+ }
1585
+
1586
+ const region = roborockAuth.getRegionConfig(this.baseURL);
1587
+ const loginResult = await roborockAuth.loginWithCode(this.loginApi, {
1588
+ email: this.config.username,
1589
+ code,
1590
+ country: region.country,
1591
+ countryCode: region.countryCode,
1592
+ k: signData.k,
1593
+ s: signData.s,
1594
+ });
1595
+
1596
+ if (loginResult && loginResult.code === 200 && loginResult.data) {
1597
+ this.userData = loginResult.data;
1598
+ this.pendingAuth = null;
1599
+ await this.setStateAsync("UserData", {
1600
+ val: JSON.stringify(this.userData),
1601
+ ack: true,
1602
+ });
1603
+ this.authState.twoFactorRequired = false;
1604
+ this.authState.statusMessage = "Two-factor authentication completed.";
1605
+ return this.userData;
1606
+ }
1607
+
1608
+ this.log.error(`2FA verification failed: ${JSON.stringify(loginResult)}`);
1609
+ throw new Error(
1610
+ `2FA verification failed: ${loginResult?.msg || "Unknown error"}`
1611
+ );
1612
+ }
1613
+
1614
+ async getNetworkInfo() {
1615
+ for (const device of this.devices) {
1616
+ const duid = device.duid;
1617
+ if (!this.hasInitializedVacuum(duid)) {
1618
+ continue;
1619
+ }
1620
+
1621
+ await this.vacuums[duid].getParameter(duid, "get_network_info");
1622
+ }
1623
+ }
1624
+
1625
+ async createDevices() {
1626
+ const devices = this.devices;
1627
+ this.initializedVacuumDuids.clear();
1628
+
1629
+ for (const device of devices) {
1630
+ const duid = device.duid;
1631
+ const name = device.name;
1632
+
1633
+ this.log.debug(`Creating device: ${name} with duid: ${duid}`);
1634
+
1635
+ // B01/Q7 robots are cloud/MQTT-only: mark them remote up front so the
1636
+ // transport layer never attempts local TCP connections to them.
1637
+ if (b01Q7Adapter.isB01Protocol(device.pv)) {
1638
+ this.remoteDevices.add(duid);
1639
+ }
1640
+
1641
+ const robotModel = this.getProductAttribute(duid, "model");
1642
+
1643
+ // model must start with "roborock.vacuum."
1644
+ if (!this.isSupportedVacuumModel(robotModel)) {
1645
+ this.log.warn(
1646
+ `Unsupported vacuum model '${robotModel || "unknown"}' for device ${duid}; skipping initialization.`
1647
+ );
1648
+ continue;
1649
+ }
1650
+
1651
+ this.vacuums[duid] = new vacuum_class(this, robotModel);
1652
+ this.initializedVacuumDuids.add(duid);
1653
+ this.vacuums[duid].name = name;
1654
+ this.vacuums[duid].features = new deviceFeatures(
1655
+ this,
1656
+ device.featureSet,
1657
+ device.newFeatureSet,
1658
+ duid
1659
+ );
1660
+
1661
+ await this.vacuums[duid].features.processSupportedFeatures();
1662
+
1663
+ await this.vacuums[duid].setUpObjects(duid);
1664
+
1665
+ // sub to all commands of this robot
1666
+ this.subscribeStates("Devices." + duid + ".commands.*");
1667
+ this.subscribeStates("Devices." + duid + ".reset_consumables.*");
1668
+ this.subscribeStates("Devices." + duid + ".programs.startProgram");
1669
+ this.subscribeStates("Devices." + duid + ".deviceInfo.online");
1670
+ }
1671
+
1672
+ // Start AFTER the loop: the loop's device gate reads
1673
+ // initializedVacuumDuids, which is only fully populated at this point.
1674
+ // (Calling it per-device made the start depend on device ordering.)
1675
+ this.startB01StatusLoop();
1676
+ }
1677
+
1678
+ async initializeDeviceUpdates() {
1679
+ this.log.debug(`initializeDeviceUpdates`);
1680
+
1681
+ const devices = this.devices;
1682
+
1683
+ for (const device of devices) {
1684
+ const duid = device.duid;
1685
+ if (!this.hasInitializedVacuum(duid)) {
1686
+ continue;
1687
+ }
1688
+
1689
+ const robotModel = this.getProductAttribute(duid, "model");
1690
+
1691
+ // The starter functions store the REAL interval handles on the vacuum
1692
+ // (self-clearing on restart). Historically the properties held the
1693
+ // starter functions themselves, so clearInterval() calls were no-ops
1694
+ // and the "restart when missing" check could never fire — one offline
1695
+ // flap killed polling forever.
1696
+ this.vacuums[duid].mainUpdateInterval = () => {
1697
+ this.clearInterval(this.vacuums[duid].mainUpdateIntervalHandle);
1698
+ this.vacuums[duid].mainUpdateIntervalHandle = this.setInterval(
1699
+ this.updateDataMinimumData.bind(this),
1700
+ this.updateInterval * 1000,
1701
+ duid,
1702
+ this.vacuums[duid],
1703
+ robotModel
1704
+ );
1705
+ return this.vacuums[duid].mainUpdateIntervalHandle;
1706
+ };
1707
+
1708
+ if (device.online) {
1709
+ this.log.debug(`${duid} online. Starting mainUpdateInterval.`);
1710
+ this.vacuums[duid].mainUpdateInterval(); // actually start mainUpdateInterval()
1711
+ }
1712
+
1713
+ this.vacuums[duid].getStatusIntervall = () => {
1714
+ // B01/Q7 status is owned by the dedicated 15s loop; the per-device
1715
+ // 1-second tick would only burn cycles hitting the attempt throttle.
1716
+ if (
1717
+ this.getVacuumDeviceInfo(duid, "pv") ===
1718
+ b01Q7Adapter.B01_PROTOCOL_VERSION
1719
+ ) {
1720
+ return null;
1721
+ }
1722
+ this.clearInterval(this.vacuums[duid].getStatusIntervalHandle);
1723
+ this.vacuums[duid].getStatusIntervalHandle = this.setInterval(
1724
+ this.getStatus.bind(this),
1725
+ 1000,
1726
+ duid,
1727
+ this.vacuums[duid],
1728
+ robotModel
1729
+ );
1730
+ return this.vacuums[duid].getStatusIntervalHandle;
1731
+ };
1732
+
1733
+ if (device.online) {
1734
+ this.log.debug(`${duid} online. Starting getStatusIntervall.`);
1735
+ this.vacuums[duid].getStatusIntervall(); // actually start getStatusIntervall()
1736
+ }
1737
+
1738
+ await this.updateDataMinimumData(duid, this.vacuums[duid], robotModel);
1739
+ }
1740
+ }
1741
+
1742
+
1743
+ async executeScene(sceneID) {
1744
+ if (this.api) {
1745
+ try {
1746
+ await this.api.post(`user/scene/${sceneID.val}/execute`);
1747
+ } catch (error) {
1748
+ this.catchError(error.stack, "executeScene");
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ /**
1754
+ * Get the home ID from the login API
1755
+ * @returns {Promise<string>} The home ID
1756
+ */
1757
+ async getHomeID() {
1758
+ if (!this.loginApi) {
1759
+ throw new Error("loginApi is not initialized. Call init() first.");
1760
+ }
1761
+
1762
+ try {
1763
+ const homeDetail = await this.loginApi.get("api/v1/getHomeDetail");
1764
+ if (homeDetail && homeDetail.data && homeDetail.data.data) {
1765
+ return homeDetail.data.data.rrHomeId;
1766
+ }
1767
+ throw new Error("Failed to get home ID from homeDetail response");
1768
+ } catch (error) {
1769
+ this.log.error(`Failed to get home ID: ${error.message}`);
1770
+ throw error;
1771
+ }
1772
+ }
1773
+
1774
+ /**
1775
+ * Get scenes from the Roborock API
1776
+ * @returns {Promise<Object>} The scenes data
1777
+ */
1778
+
1779
+ /**
1780
+ * Get scenes for a specific device by duid
1781
+ * @param {string} duid - The device unique identifier
1782
+ * @returns {Array} Array of scenes for the specified device
1783
+ */
1784
+
1785
+ getProductAttribute(duid, attribute) {
1786
+ const device = this.getVacuumDeviceData(duid);
1787
+ const deviceValue = this.getDeviceAttribute(device, attribute);
1788
+ if (deviceValue !== null) {
1789
+ return deviceValue;
1790
+ }
1791
+
1792
+ const products = this.getKnownProducts();
1793
+ const productID = device?.productId;
1794
+ const product = products.find((entry) => entry.id == productID);
1795
+
1796
+ if (!product) {
1797
+ return null;
1798
+ }
1799
+
1800
+ const productValue = this.getDeviceAttribute(product, attribute);
1801
+ return productValue !== null ? productValue : null;
1802
+ }
1803
+
1804
+ getVacuumSchemaCodes(duid) {
1805
+ const productId = this.getVacuumDeviceInfo(duid, "productId");
1806
+ const product = this.getProductData(productId);
1807
+ return this.normalizeArray(product?.schema)
1808
+ .map((schema) => schema?.code)
1809
+ .filter((code) => typeof code == "string" && code.trim());
1810
+ }
1811
+
1812
+ hasVacuumSchemaCode(duid, codes) {
1813
+ const requestedCodes = Array.isArray(codes) ? codes : [codes];
1814
+ const schemaCodes = new Set(this.getVacuumSchemaCodes(duid));
1815
+ return requestedCodes.some((code) => schemaCodes.has(code));
1816
+ }
1817
+
1818
+ hasVacuumFeature(duid, features) {
1819
+ const requestedFeatures = Array.isArray(features) ? features : [features];
1820
+ const featureList = this.vacuums[duid]?.features?.getFeatureList?.();
1821
+ if (!featureList) {
1822
+ return false;
1823
+ }
1824
+
1825
+ return requestedFeatures.some((feature) => Boolean(featureList[feature]));
1826
+ }
1827
+
1828
+ getMatterCleanModeCapabilities(duid) {
1829
+ // Q7-series (B01) robots use a manually filled water tank on the robot
1830
+ // with no electronic mop/water control, so Matter must never expose mop
1831
+ // modes for them regardless of what the generic cloud schema claims.
1832
+ // Suction (Q7 "wind") is controllable via the B01 adapter.
1833
+ if (this.getVacuumDeviceInfo(duid, "pv") === b01Q7Adapter.B01_PROTOCOL_VERSION) {
1834
+ return {
1835
+ // Q7 robots mop with a manually filled tank: expose the mop/vacuum
1836
+ // mode switch, but never water-level status or control.
1837
+ canVacuum: true,
1838
+ canMop: true,
1839
+ canControlFanPower: true,
1840
+ canControlWater: false,
1841
+ };
1842
+ }
1843
+
1844
+ const canControlFanPower =
1845
+ this.hasVacuumSchemaCode(duid, "fan_power") ||
1846
+ this.getVacuumDeviceStatus(duid, "fan_power") !== "";
1847
+ const hasWaterModeSchema = this.hasVacuumSchemaCode(duid, [
1848
+ "water_box_mode",
1849
+ "water_box_custom_mode",
1850
+ ]);
1851
+ const hasMopSchema = this.hasVacuumSchemaCode(duid, [
1852
+ "mop_mode",
1853
+ "mop_forbidden_enable",
1854
+ ]);
1855
+ const hasMopFeature = this.hasVacuumFeature(duid, [
1856
+ "isSupportedWaterMode",
1857
+ "isShakeMopSetSupported",
1858
+ "isElectronicWaterBoxSupported",
1859
+ "isCleanRouteFastModeSupported",
1860
+ "isMopForbiddenSupported",
1861
+ "isShakeMopStrengthSupported",
1862
+ "isWaterBoxSupported",
1863
+ ]);
1864
+
1865
+ return {
1866
+ canVacuum: true,
1867
+ canMop: hasWaterModeSchema || hasMopSchema || hasMopFeature,
1868
+ canControlFanPower,
1869
+ canControlWater:
1870
+ hasWaterModeSchema ||
1871
+ this.hasVacuumFeature(duid, [
1872
+ "isSupportedWaterMode",
1873
+ "isShakeMopSetSupported",
1874
+ "isElectronicWaterBoxSupported",
1875
+ "isShakeMopStrengthSupported",
1876
+ ]),
1877
+ };
1878
+ }
1879
+
1880
+ buildCommandOptions(options, extraDefaults = {}) {
1881
+ const waitForResult = Boolean(options.waitForResult);
1882
+ const commandOptions = waitForResult
1883
+ ? { ...extraDefaults, ...options, throwOnError: true }
1884
+ : { ...extraDefaults, ...options };
1885
+
1886
+ return { waitForResult, commandOptions };
1887
+ }
1888
+
1889
+ async applyMatterCleanModeSettings(duid, settings, options = {}) {
1890
+ const { commandOptions } = this.buildCommandOptions(options, {
1891
+ requestTimeoutMs: MATTER_CLEAN_MODE_COMMAND_TIMEOUT_MS,
1892
+ });
1893
+
1894
+ // Q7/B01: the robot has a native clean-type concept (vacuum / mop /
1895
+ // vacuum+mop via the `mode` property), so apply the Matter selection
1896
+ // directly. The v1-style workarounds (fan power OFF to fake mop-only,
1897
+ // water box modes) do not apply — Q7 water is a manual tank.
1898
+ if (
1899
+ this.getVacuumDeviceInfo(duid, "pv") === b01Q7Adapter.B01_PROTOCOL_VERSION
1900
+ ) {
1901
+ if (Number.isInteger(settings?.cleanMode)) {
1902
+ try {
1903
+ await this.startCommand(
1904
+ duid,
1905
+ "set_clean_type",
1906
+ [settings.cleanMode],
1907
+ commandOptions
1908
+ );
1909
+ } catch (error) {
1910
+ this.log.debug(
1911
+ `B01 clean-type command failed for ${duid}; continuing with the start command. ${error.message || error}`
1912
+ );
1913
+ }
1914
+ }
1915
+
1916
+ const fanPower = settings?.fanPower;
1917
+ if (Number.isInteger(fanPower) && fanPower !== 105) {
1918
+ try {
1919
+ await this.startCommand(
1920
+ duid,
1921
+ "set_custom_mode",
1922
+ [fanPower],
1923
+ commandOptions
1924
+ );
1925
+ } catch (error) {
1926
+ this.log.debug(
1927
+ `B01 suction command failed for ${duid}; continuing with the start command. ${error.message || error}`
1928
+ );
1929
+ }
1930
+ }
1931
+
1932
+ return;
1933
+ }
1934
+
1935
+ if (
1936
+ Number.isInteger(settings?.fanPower) &&
1937
+ this.getMatterCleanModeCapabilities(duid).canControlFanPower
1938
+ ) {
1939
+ try {
1940
+ await this.runMatterSettingCommand(
1941
+ duid,
1942
+ "set_custom_mode",
1943
+ settings.fanPower,
1944
+ commandOptions
1945
+ );
1946
+ } catch (error) {
1947
+ this.rememberUnsupportedMatterSettingCommand(
1948
+ duid,
1949
+ "set_custom_mode",
1950
+ error
1951
+ );
1952
+ if (this.isMatterSettingTimeoutError(error)) {
1953
+ this.log.debug(
1954
+ `Matter clean mode fan command timed out for ${duid}; skipping remaining clean-mode prep and continuing with start command. ${error.message || error}`
1955
+ );
1956
+ return;
1957
+ }
1958
+ this.log.debug(
1959
+ `Matter clean mode fan command failed for ${duid}; continuing with start command. ${error.message || error}`
1960
+ );
1961
+ }
1962
+ }
1963
+
1964
+ if (!Number.isInteger(settings?.waterBoxMode)) {
1965
+ return;
1966
+ }
1967
+
1968
+ const waterCommands = this.getMatterWaterModeCommandCandidates(duid);
1969
+ if (waterCommands.length === 0) {
1970
+ this.log.debug(
1971
+ `Matter clean mode requested water mode ${settings.waterBoxMode} for ${duid}, but no supported Roborock water command was detected.`
1972
+ );
1973
+ return;
1974
+ }
1975
+
1976
+ try {
1977
+ await this.runFirstMatterSettingCommand(
1978
+ duid,
1979
+ waterCommands,
1980
+ settings.waterBoxMode,
1981
+ commandOptions
1982
+ );
1983
+ } catch (error) {
1984
+ this.log.debug(
1985
+ `Matter clean mode water commands failed for ${duid}; continuing with start command. ${error.message || error}`
1986
+ );
1987
+ }
1988
+ }
1989
+
1990
+ getMatterWaterModeCommandCandidates(duid) {
1991
+ const commands = [];
1992
+
1993
+ if (this.hasVacuumSchemaCode(duid, "water_box_mode")) {
1994
+ commands.push("set_water_box_mode");
1995
+ commands.push("set_water_box_custom_mode");
1996
+ }
1997
+
1998
+ if (
1999
+ this.hasVacuumSchemaCode(duid, "water_box_custom_mode") ||
2000
+ this.hasVacuumFeature(duid, [
2001
+ "isSupportedWaterMode",
2002
+ "isShakeMopSetSupported",
2003
+ "isElectronicWaterBoxSupported",
2004
+ "isShakeMopStrengthSupported",
2005
+ ])
2006
+ ) {
2007
+ commands.push("set_water_box_custom_mode");
2008
+ }
2009
+
2010
+ if (
2011
+ commands.length === 0 &&
2012
+ this.getMatterCleanModeCapabilities(duid).canControlWater
2013
+ ) {
2014
+ commands.push("set_water_box_custom_mode");
2015
+ }
2016
+
2017
+ return [...new Set(commands)].filter(
2018
+ (command) =>
2019
+ !this.matterUnsupportedSettingCommands.has(
2020
+ this.getMatterSettingCommandKey(duid, command)
2021
+ )
2022
+ );
2023
+ }
2024
+
2025
+ async runFirstMatterSettingCommand(duid, commands, value, options = {}) {
2026
+ let lastError = null;
2027
+
2028
+ for (const command of commands) {
2029
+ try {
2030
+ await this.runMatterSettingCommand(duid, command, value, options);
2031
+ return;
2032
+ } catch (error) {
2033
+ lastError = error;
2034
+ const canTryFallback =
2035
+ this.shouldRememberUnsupportedMatterCommand(error);
2036
+ if (canTryFallback) {
2037
+ this.rememberUnsupportedMatterSettingCommand(duid, command, error);
2038
+ this.log.debug(
2039
+ `Matter clean mode command ${command} failed for ${duid}; trying another water command if available. ${error.message || error}`
2040
+ );
2041
+ continue;
2042
+ }
2043
+
2044
+ this.log.debug(
2045
+ `Matter clean mode command ${command} failed for ${duid}; not trying fallback commands before start. ${error.message || error}`
2046
+ );
2047
+ throw error;
2048
+ }
2049
+ }
2050
+
2051
+ if (lastError) {
2052
+ throw lastError;
2053
+ }
2054
+ }
2055
+
2056
+ async runMatterSettingCommand(duid, command, value, options = {}) {
2057
+ if (!this.isInited()) {
2058
+ this.log.warn("Adapter not inited. Command not executed.");
2059
+ return;
2060
+ }
2061
+
2062
+ const vacuum = this.vacuums[duid];
2063
+ if (!vacuum || typeof vacuum.command != "function") {
2064
+ throw new Error(`Vacuum ${duid} is not initialized.`);
2065
+ }
2066
+
2067
+ const result = await vacuum.command(duid, command, value, options);
2068
+ if (this.shouldRememberUnsupportedMatterCommand(result)) {
2069
+ throw new Error(
2070
+ `${command} returned unsupported result: ${JSON.stringify(result)}`
2071
+ );
2072
+ }
2073
+ return result;
2074
+ }
2075
+
2076
+ getMatterSettingCommandKey(duid, command) {
2077
+ return `${duid}:${command}`;
2078
+ }
2079
+
2080
+ rememberUnsupportedMatterSettingCommand(duid, command, error) {
2081
+ if (this.shouldRememberUnsupportedMatterCommand(error)) {
2082
+ this.matterUnsupportedSettingCommands.add(
2083
+ this.getMatterSettingCommandKey(duid, command)
2084
+ );
2085
+ }
2086
+ }
2087
+
2088
+ shouldRememberUnsupportedMatterCommand(error) {
2089
+ const message = `${error?.message || error || ""}`.toLowerCase();
2090
+ return [
2091
+ "unsupported",
2092
+ "not supported",
2093
+ "unknown method",
2094
+ "unknown_method",
2095
+ "method not found",
2096
+ "invalid method",
2097
+ "unknown parameter",
2098
+ ].some((pattern) => message.includes(pattern));
2099
+ }
2100
+
2101
+ isMatterSettingTimeoutError(error) {
2102
+ const message = `${error?.message || error || ""}`.toLowerCase();
2103
+ return message.includes("request") && message.includes("timed out after");
2104
+ }
2105
+
2106
+ startMainUpdateInterval(duid, online) {
2107
+ if (!this.hasInitializedVacuum(duid)) {
2108
+ return;
2109
+ }
2110
+
2111
+ const robotModel = this.getProductAttribute(duid, "model");
2112
+
2113
+ this.vacuums[duid].mainUpdateInterval = () => {
2114
+ this.clearInterval(this.vacuums[duid].mainUpdateIntervalHandle);
2115
+ this.vacuums[duid].mainUpdateIntervalHandle = this.setInterval(
2116
+ this.updateDataMinimumData.bind(this),
2117
+ this.updateInterval * 1000,
2118
+ duid,
2119
+ this.vacuums[duid],
2120
+ robotModel
2121
+ );
2122
+ return this.vacuums[duid].mainUpdateIntervalHandle;
2123
+ };
2124
+ if (online) {
2125
+ this.log.debug(`${duid} online. Starting mainUpdateInterval.`);
2126
+ this.vacuums[duid].mainUpdateInterval(); // actually start mainUpdateInterval()
2127
+ // Map updater gets startet automatically via getParameter with get_status
2128
+ }
2129
+ }
2130
+
2131
+ decodeSniffedMessage(data, devices) {
2132
+ const dataString = JSON.stringify(data);
2133
+
2134
+ const duidMatch = dataString.match(/\/(\w+)\.\w{3}'/);
2135
+ if (duidMatch) {
2136
+ const duidSniffed = duidMatch[1];
2137
+
2138
+ const device = devices.find((device) => device.duid === duidSniffed);
2139
+ if (device) {
2140
+ const localKey = device.localKey;
2141
+
2142
+ const payloadMatch = dataString.match(/'([a-fA-F0-9]+)'/);
2143
+ if (payloadMatch) {
2144
+ const hexPayload = payloadMatch[1];
2145
+ const msg = Buffer.from(hexPayload, "hex");
2146
+
2147
+ const decodedMessage = this.message._decodeMsg(msg, localKey);
2148
+ this.log.debug(
2149
+ `Decoded sniffing message: ${JSON.stringify(JSON.parse(decodedMessage.payload))}`
2150
+ );
2151
+ }
2152
+ }
2153
+ }
2154
+ }
2155
+
2156
+ async onlineChecker(duid) {
2157
+ const homedataJSON = this.getStoredHomeData();
2158
+
2159
+ // If the home data is not found or if its value is not a string, return false.
2160
+ if (homedataJSON) {
2161
+ const device = homedataJSON.devices.find((device) => device.duid == duid);
2162
+ const receivedDevice = homedataJSON.receivedDevices.find(
2163
+ (device) => device.duid == duid
2164
+ );
2165
+
2166
+ // If the device is not found, return false.
2167
+ if (!device && !receivedDevice) {
2168
+ return false;
2169
+ }
2170
+
2171
+ const onlineState = device?.online || receivedDevice?.online;
2172
+ await this.updateTransportDiagnostics(duid, {
2173
+ online: Boolean(onlineState),
2174
+ });
2175
+ return onlineState;
2176
+ } else {
2177
+ await this.updateTransportDiagnostics(duid, {
2178
+ online: false,
2179
+ });
2180
+ return false;
2181
+ }
2182
+ }
2183
+
2184
+ async isRemoteDevice(duid) {
2185
+ const homedataJSON = this.getStoredHomeData();
2186
+
2187
+ if (homedataJSON) {
2188
+ const receivedDevice = homedataJSON.receivedDevices.find(
2189
+ (device) => device.duid == duid
2190
+ );
2191
+ const remoteDevice = this.remoteDevices.has(duid);
2192
+
2193
+ if (receivedDevice || remoteDevice) {
2194
+ await this.updateTransportDiagnostics(duid, {
2195
+ isRemote: true,
2196
+ remoteReason: receivedDevice
2197
+ ? "received-device"
2198
+ : "marked-remote-after-connect-failure",
2199
+ });
2200
+ return true;
2201
+ }
2202
+
2203
+ await this.updateTransportDiagnostics(duid, {
2204
+ isRemote: false,
2205
+ remoteReason: null,
2206
+ });
2207
+ return false;
2208
+ } else {
2209
+ await this.updateTransportDiagnostics(duid, {
2210
+ isRemote: false,
2211
+ });
2212
+ return false;
2213
+ }
2214
+ }
2215
+
2216
+ async getConnector(duid) {
2217
+ const isRemote = await this.isRemoteDevice(duid);
2218
+
2219
+ if (isRemote) {
2220
+ return this.rr_mqtt_connector;
2221
+ } else {
2222
+ return this.localConnector;
2223
+ }
2224
+ }
2225
+
2226
+ async manageDeviceIntervals(duid) {
2227
+ if (!this.hasInitializedVacuum(duid)) {
2228
+ return false;
2229
+ }
2230
+
2231
+ return this.onlineChecker(duid)
2232
+ .then((onlineState) => {
2233
+ const vacuum = this.vacuums[duid];
2234
+ if (!onlineState && vacuum.mainUpdateIntervalHandle) {
2235
+ this.clearInterval(vacuum.getStatusIntervalHandle);
2236
+ this.clearInterval(vacuum.mainUpdateIntervalHandle);
2237
+ vacuum.getStatusIntervalHandle = null;
2238
+ vacuum.mainUpdateIntervalHandle = null;
2239
+ } else if (onlineState && !vacuum.mainUpdateIntervalHandle) {
2240
+ vacuum.getStatusIntervall();
2241
+ this.startMainUpdateInterval(duid, onlineState);
2242
+ }
2243
+ return onlineState;
2244
+ })
2245
+ .catch((error) => {
2246
+ this.log.error("startStopIntervals " + error);
2247
+
2248
+ return false; // Make device appear as offline on error. Just in case.
2249
+ });
2250
+ }
2251
+
2252
+ isSupportedVacuumModel(model) {
2253
+ return typeof model === "string" && model.startsWith("roborock.vacuum.");
2254
+ }
2255
+
2256
+ hasInitializedVacuum(duid) {
2257
+ return this.initializedVacuumDuids.has(duid) && !!this.vacuums[duid];
2258
+ }
2259
+
2260
+ async updateDataMinimumData(duid, vacuum, robotModel) {
2261
+ this.log.debug(`Latest data requested`);
2262
+
2263
+ if (this.isSupportedVacuumModel(robotModel)) {
2264
+ const refreshedServiceAreaRooms =
2265
+ await this.refreshMatterServiceAreaRoomMappings(duid, vacuum);
2266
+
2267
+ if (!refreshedServiceAreaRooms) {
2268
+ await vacuum.getParameter(duid, "get_room_mapping");
2269
+ }
2270
+
2271
+ await vacuum.getParameter(duid, "get_consumable");
2272
+
2273
+ await vacuum.getParameter(duid, "get_server_timer");
2274
+
2275
+ await vacuum.getParameter(duid, "get_timer");
2276
+
2277
+ await this.checkForNewFirmware(duid);
2278
+
2279
+ switch (robotModel) {
2280
+ case "roborock.vacuum.s4":
2281
+ case "roborock.vacuum.s5":
2282
+ case "roborock.vacuum.s5e":
2283
+ case "roborock.vacuum.a08":
2284
+ case "roborock.vacuum.a10":
2285
+ case "roborock.vacuum.a40":
2286
+ case "roborock.vacuum.a140":
2287
+ case "roborock.vacuum.a95":
2288
+ case "roborock.vacuum.a159":
2289
+ case "roborock.vacuum.ss07":
2290
+ //do nothing
2291
+ break;
2292
+ case "roborock.vacuum.s6":
2293
+ await vacuum.getParameter(duid, "get_carpet_mode");
2294
+ break;
2295
+ case "roborock.vacuum.a27":
2296
+ await vacuum.getParameter(duid, "get_dust_collection_switch_status");
2297
+ await vacuum.getParameter(duid, "get_wash_towel_mode");
2298
+ await vacuum.getParameter(duid, "get_smart_wash_params");
2299
+ await vacuum.getParameter(duid, "app_get_dryer_setting");
2300
+ break;
2301
+ default:
2302
+ await vacuum.getParameter(duid, "get_carpet_mode");
2303
+ await vacuum.getParameter(duid, "get_carpet_clean_mode");
2304
+ await vacuum.getParameter(duid, "get_water_box_custom_mode");
2305
+ }
2306
+ } else {
2307
+ this.log.warn(
2308
+ `Unsupported model '${robotModel || "unknown"}'. Skipping minimum data update for ${duid}.`
2309
+ );
2310
+ }
2311
+ }
2312
+
2313
+ async updateDataExtraData(duid, vacuum) {
2314
+ try {
2315
+ await vacuum.getParameter(duid, "get_fw_features");
2316
+ await vacuum.getParameter(duid, "get_multi_maps_list");
2317
+ } catch (error) {
2318
+ this.log.error(
2319
+ `Failed to get extra data for ${vacuum}: ${error.message}`
2320
+ );
2321
+ }
2322
+ }
2323
+
2324
+ async refreshMatterServiceAreaRoomMappings(duid, vacuum) {
2325
+ if (
2326
+ !this.config.enableMatterServiceArea &&
2327
+ !this.config.enableMatterServiceAreaBeta
2328
+ ) {
2329
+ return false;
2330
+ }
2331
+
2332
+ // Room/map data on B01 (Q7-series) robots travels over the protobuf map
2333
+ // channel instead of the classic get_room_mapping flow.
2334
+ const robotVersion = await this.getRobotVersion(duid);
2335
+ if (b01Q7Adapter.isB01Protocol(robotVersion)) {
2336
+ // With a persisted room cache, Service Area can expose rooms
2337
+ // immediately; run the refresh in the background so a slow map channel
2338
+ // never delays startup or the caller.
2339
+ if (this.getB01RoomCache(duid).length > 0) {
2340
+ void this.refreshB01Rooms(duid).catch((error) => {
2341
+ this.log.debug(
2342
+ `Background B01 room refresh failed for ${duid}: ${error.message || error}`
2343
+ );
2344
+ });
2345
+ return true;
2346
+ }
2347
+
2348
+ try {
2349
+ await this.refreshB01Rooms(duid);
2350
+ return true;
2351
+ } catch (error) {
2352
+ this.log.debug(
2353
+ `B01 room refresh failed for ${duid}: ${error.message || error}`
2354
+ );
2355
+ return false;
2356
+ }
2357
+ }
2358
+
2359
+ try {
2360
+ await vacuum.getParameter(duid, "get_multi_maps_list");
2361
+ await vacuum.getParameter(duid, "get_room_mapping");
2362
+ await this.cacheMissingMatterServiceAreaRoomMappings(duid, vacuum);
2363
+ return true;
2364
+ } catch (error) {
2365
+ this.log.debug(
2366
+ `Failed to refresh Matter Service Area room mappings for ${duid}: ${error.message || error}`
2367
+ );
2368
+ return false;
2369
+ }
2370
+ }
2371
+
2372
+ async cacheMissingMatterServiceAreaRoomMappings(duid, vacuum) {
2373
+ const maps = this.getMapListForDevice(duid);
2374
+ if (maps.length < 2) {
2375
+ return;
2376
+ }
2377
+
2378
+ const missingMaps = maps.filter(
2379
+ (map) =>
2380
+ this.getRoomMappingsForMap(duid, map.mapId).length === 0 &&
2381
+ this.shouldAttemptServiceAreaRoomMapRefresh(duid, map.mapId)
2382
+ );
2383
+ if (missingMaps.length === 0) {
2384
+ return;
2385
+ }
2386
+
2387
+ const state = this.getCachedVacuumState(duid);
2388
+ if (this.isCleaning(state)) {
2389
+ this.log.debug(
2390
+ `Skipping Matter Service Area room-map refresh for ${duid}; robot is busy.`
2391
+ );
2392
+ return;
2393
+ }
2394
+
2395
+ const originalMapId = this.getCurrentMapIdForDevice(duid);
2396
+
2397
+ try {
2398
+ for (const map of missingMaps) {
2399
+ this.markServiceAreaRoomMapRefreshAttempt(duid, map.mapId);
2400
+
2401
+ try {
2402
+ if (map.mapId === originalMapId) {
2403
+ await vacuum.getParameter(duid, "get_room_mapping");
2404
+ } else {
2405
+ this.log.info(
2406
+ `Loading Roborock map '${map.name}' for ${duid} to cache Matter Service Area rooms.`
2407
+ );
2408
+ await vacuum.command(duid, "load_multi_map", map.mapId, {
2409
+ throwOnError: true,
2410
+ });
2411
+ }
2412
+ } catch (error) {
2413
+ // A single slow/failed map switch must not abort the remaining maps
2414
+ // or skip restoring the original map below.
2415
+ this.log.debug(
2416
+ `Failed to load Roborock map '${map.name}' for ${duid} while caching Matter Service Area rooms: ${error.message || error}`
2417
+ );
2418
+ continue;
2419
+ }
2420
+
2421
+ if (this.getRoomMappingsForMap(duid, map.mapId).length === 0) {
2422
+ this.log.info(
2423
+ `Roborock map '${map.name}' for ${duid} did not return room mappings. It will appear in Matter once Roborock reports room segment IDs for that saved map.`
2424
+ );
2425
+ }
2426
+ }
2427
+ } finally {
2428
+ // Always try to put the robot back on the map it started on, even if a
2429
+ // load above timed out, so the refresh never leaves it on another map.
2430
+ await this.restoreServiceAreaOriginalMap(
2431
+ duid,
2432
+ vacuum,
2433
+ maps,
2434
+ originalMapId
2435
+ );
2436
+ }
2437
+ }
2438
+
2439
+ shouldAttemptServiceAreaRoomMapRefresh(duid, mapId) {
2440
+ const key = this.getServiceAreaRoomMapRefreshKey(duid, mapId);
2441
+ const lastAttempt = this.serviceAreaRoomMapRefreshAttempts.get(key);
2442
+
2443
+ return (
2444
+ lastAttempt === undefined ||
2445
+ this.now() - lastAttempt >= SERVICE_AREA_ROOM_MAP_REFRESH_TTL_MS
2446
+ );
2447
+ }
2448
+
2449
+ markServiceAreaRoomMapRefreshAttempt(duid, mapId) {
2450
+ this.serviceAreaRoomMapRefreshAttempts.set(
2451
+ this.getServiceAreaRoomMapRefreshKey(duid, mapId),
2452
+ this.now()
2453
+ );
2454
+ }
2455
+
2456
+ async restoreServiceAreaOriginalMap(duid, vacuum, maps, originalMapId) {
2457
+ if (originalMapId === null) {
2458
+ return;
2459
+ }
2460
+
2461
+ const currentMapId = this.getCurrentMapIdForDevice(duid);
2462
+ if (currentMapId === null || currentMapId === originalMapId) {
2463
+ return;
2464
+ }
2465
+
2466
+ const originalMap = maps.find((map) => map.mapId === originalMapId);
2467
+ this.log.info(
2468
+ `Restoring Roborock map '${originalMap?.name || originalMapId}' for ${duid} after caching Matter Service Area rooms.`
2469
+ );
2470
+
2471
+ try {
2472
+ await vacuum.command(duid, "load_multi_map", originalMapId, {
2473
+ throwOnError: true,
2474
+ });
2475
+ } catch (error) {
2476
+ this.log.warn(
2477
+ `Failed to restore Roborock map '${originalMap?.name || originalMapId}' for ${duid} after caching Matter Service Area rooms: ${error.message || error}. The robot may stay on another saved map until the next refresh.`
2478
+ );
2479
+ }
2480
+ }
2481
+
2482
+ getCachedVacuumState(duid) {
2483
+ const cachedState = this.getStateAsync(
2484
+ `Devices.${duid}.deviceStatus.state`
2485
+ );
2486
+ const cachedValue = Number(cachedState?.val);
2487
+ if (Number.isFinite(cachedValue)) {
2488
+ return cachedValue;
2489
+ }
2490
+
2491
+ const deviceStatus = this.getVacuumDeviceInfo(duid, "deviceStatus");
2492
+ const homeDataValue = Number(deviceStatus?.state);
2493
+ return Number.isFinite(homeDataValue) ? homeDataValue : null;
2494
+ }
2495
+
2496
+ getServiceAreaRoomMapRefreshKey(duid, mapId) {
2497
+ return `${duid}:${mapId}`;
2498
+ }
2499
+
2500
+ clearTimersAndIntervals() {
2501
+ if (this.reconnectIntervall) {
2502
+ this.clearInterval(this.reconnectIntervall);
2503
+ }
2504
+ if (this.homedataInterval) {
2505
+ this.clearInterval(this.homedataInterval);
2506
+ }
2507
+ if (this.commandTimeout) {
2508
+ this.clearTimeout(this.commandTimeout);
2509
+ }
2510
+
2511
+ this.localConnector.clearLocalDevicedTimeout();
2512
+
2513
+ for (const duid in this.vacuums) {
2514
+ this.clearInterval(this.vacuums[duid].getStatusIntervalHandle);
2515
+ this.clearInterval(this.vacuums[duid].mainUpdateIntervalHandle);
2516
+ this.vacuums[duid].getStatusIntervalHandle = null;
2517
+ this.vacuums[duid].mainUpdateIntervalHandle = null;
2518
+ }
2519
+
2520
+ if (this.b01StatusLoopHandle) {
2521
+ this.clearInterval(this.b01StatusLoopHandle);
2522
+ this.b01StatusLoopHandle = null;
2523
+ }
2524
+
2525
+ this.messageQueue.forEach(({ timeout102, timeout301 }) => {
2526
+ this.clearTimeout(timeout102);
2527
+ if (timeout301) {
2528
+ this.clearTimeout(timeout301);
2529
+ }
2530
+ });
2531
+
2532
+ // Clear the messageQueue map
2533
+ this.messageQueue.clear();
2534
+
2535
+ if (this.webSocketInterval) {
2536
+ this.clearInterval(this.webSocketInterval);
2537
+ }
2538
+ }
2539
+
2540
+ checkAndClearRequest(requestId) {
2541
+ const request = this.messageQueue.get(requestId);
2542
+ if (!request?.timeout102 && !request?.timeout301) {
2543
+ this.messageQueue.delete(requestId);
2544
+ // this.log.debug(`Cleared messageQueue`);
2545
+ } else {
2546
+ this.log.debug(
2547
+ `Not clearing messageQueue. ${request.timeout102} - ${request.timeout301}`
2548
+ );
2549
+ }
2550
+ this.log.debug(`Length of message queue: ${this.messageQueue.size}`);
2551
+ }
2552
+
2553
+ async updateHomeData(homeId) {
2554
+ this.log.debug(`Updating HomeData with homeId: ${homeId}`);
2555
+ if (this.api) {
2556
+ try {
2557
+ const home = await this.api.get(`user/homes/${homeId}`);
2558
+ const homedata = home.data.result;
2559
+
2560
+ if (homedata) {
2561
+ this.superviseB01DeviceIntervals();
2562
+ await this.refreshLocalKeysFromHomeData(homedata);
2563
+ await this.setStateAsync("HomeData", {
2564
+ val: JSON.stringify(homedata),
2565
+ ack: true,
2566
+ });
2567
+ this.log.debug(`homedata successfully updated`);
2568
+
2569
+ await this.updateDeviceInfo(homedata.devices);
2570
+ await this.updateDeviceInfo(homedata.receivedDevices);
2571
+ } else {
2572
+ this.log.warn("homedata failed to download");
2573
+ }
2574
+ } catch (error) {
2575
+ this.log.error(`Failed to update updateHomeData with error: ${error}`);
2576
+ }
2577
+ }
2578
+ }
2579
+
2580
+
2581
+ async updateDeviceInfo(devices) {
2582
+ devices = this.normalizeArray(devices);
2583
+ for (const device in devices) {
2584
+ const duid = devices[device].duid;
2585
+
2586
+ for (const deviceAttribute in devices[device]) {
2587
+ if (typeof devices[device][deviceAttribute] != "object") {
2588
+ let unit;
2589
+ if (deviceAttribute == "activeTime") {
2590
+ unit = "h";
2591
+ devices[device][deviceAttribute] = Math.round(
2592
+ devices[device][deviceAttribute] / 1000 / 60 / 60
2593
+ );
2594
+ }
2595
+ await this.setObjectAsync(
2596
+ "Devices." + duid + ".deviceInfo." + deviceAttribute,
2597
+ {
2598
+ type: "state",
2599
+ common: {
2600
+ name: deviceAttribute,
2601
+ type: this.getType(devices[device][deviceAttribute]),
2602
+ unit: unit,
2603
+ role: "value",
2604
+ read: true,
2605
+ write: false,
2606
+ },
2607
+ native: {},
2608
+ }
2609
+ );
2610
+ this.setStateChangedAsync(
2611
+ "Devices." + duid + ".deviceInfo." + deviceAttribute,
2612
+ { val: devices[device][deviceAttribute], ack: true }
2613
+ );
2614
+ }
2615
+ }
2616
+ }
2617
+ }
2618
+
2619
+ async checkForNewFirmware(duid) {
2620
+ const isLocalDevice = !this.isRemoteDevice(duid);
2621
+
2622
+ if (isLocalDevice) {
2623
+ this.log.debug(`getting firmware status`);
2624
+ if (this.api) {
2625
+ try {
2626
+ const update = await this.api.get(`ota/firmware/${duid}/updatev2`);
2627
+
2628
+ await this.setObjectNotExistsAsync(
2629
+ "Devices." + duid + ".updateStatus",
2630
+ {
2631
+ type: "folder",
2632
+ common: {
2633
+ name: "Update status",
2634
+ },
2635
+ native: {},
2636
+ }
2637
+ );
2638
+
2639
+ for (const state in update.data.result) {
2640
+ await this.setObjectNotExistsAsync(
2641
+ "Devices." + duid + ".updateStatus." + state,
2642
+ {
2643
+ type: "state",
2644
+ common: {
2645
+ name: state,
2646
+ type: this.getType(update.data.result[state]),
2647
+ role: "value",
2648
+ read: true,
2649
+ write: false,
2650
+ },
2651
+ native: {},
2652
+ }
2653
+ );
2654
+ this.setStateAsync("Devices." + duid + ".updateStatus." + state, {
2655
+ val: update.data.result[state],
2656
+ ack: true,
2657
+ });
2658
+ }
2659
+ } catch (error) {
2660
+ this.catchError(error, "checkForNewFirmware()", duid);
2661
+ }
2662
+ }
2663
+ }
2664
+ }
2665
+
2666
+ getType(attribute) {
2667
+ // Get the type of the attribute.
2668
+ const type = typeof attribute;
2669
+
2670
+ // Return the appropriate string representation of the type.
2671
+ switch (type) {
2672
+ case "boolean":
2673
+ return "boolean";
2674
+ case "number":
2675
+ return "number";
2676
+ default:
2677
+ return "string";
2678
+ }
2679
+ }
2680
+
2681
+ async createStateObjectHelper(
2682
+ path,
2683
+ name,
2684
+ type,
2685
+ unit,
2686
+ def,
2687
+ role,
2688
+ read,
2689
+ write,
2690
+ states,
2691
+ native = {}
2692
+ ) {
2693
+ const common = {
2694
+ name: name,
2695
+ type: type,
2696
+ unit: unit,
2697
+ role: role,
2698
+ read: read,
2699
+ write: write,
2700
+ states: states,
2701
+ };
2702
+
2703
+ if (def !== undefined && def !== null && def !== "") {
2704
+ common.def = def;
2705
+ }
2706
+
2707
+ this.setObjectAsync(path, {
2708
+ type: "state",
2709
+ common: common,
2710
+ native: native,
2711
+ });
2712
+ }
2713
+
2714
+ createDeviceObject(pathSegment, duid, state, type, states, options = {}) {
2715
+ const {
2716
+ write = false,
2717
+ unit,
2718
+ hasUnit = false,
2719
+ def,
2720
+ hasDef = false,
2721
+ } = options;
2722
+ const path = `Devices.${duid}.${pathSegment}.${state}`;
2723
+ const name = this.translations[state];
2724
+
2725
+ const common = {
2726
+ name: name,
2727
+ type: type,
2728
+ role: "value",
2729
+ };
2730
+
2731
+ if (hasUnit) {
2732
+ common.unit = unit;
2733
+ }
2734
+
2735
+ common.read = true;
2736
+ common.write = write;
2737
+
2738
+ if (hasDef) {
2739
+ common.def = def;
2740
+ }
2741
+
2742
+ common.states = states;
2743
+
2744
+ this.setObjectAsync(path, {
2745
+ type: "state",
2746
+ common: common,
2747
+ native: {},
2748
+ });
2749
+ }
2750
+
2751
+ async createCommand(duid, command, type, defaultState, states) {
2752
+ this.createDeviceObject("commands", duid, command, type, states, {
2753
+ write: true,
2754
+ hasDef: true,
2755
+ def: defaultState,
2756
+ });
2757
+ }
2758
+
2759
+ async createDeviceStatus(duid, state, type, states, unit) {
2760
+ this.createDeviceObject("deviceStatus", duid, state, type, states, {
2761
+ write: false,
2762
+ hasUnit: true,
2763
+ unit,
2764
+ });
2765
+ }
2766
+
2767
+ async createDockingStationObject(duid) {
2768
+ for (const state of dockingStationStates) {
2769
+ const path = `Devices.${duid}.dockingStationStatus.${state}`;
2770
+ const name = this.translations[state];
2771
+
2772
+ this.setObjectNotExistsAsync(path, {
2773
+ type: "state",
2774
+ common: {
2775
+ name: name,
2776
+ type: "number",
2777
+ role: "value",
2778
+ read: true,
2779
+ write: false,
2780
+ states: { 0: "UNKNOWN", 1: "ERROR", 2: "OK" },
2781
+ },
2782
+ native: {},
2783
+ });
2784
+ }
2785
+ }
2786
+
2787
+ async createConsumable(duid, state, type, states, unit) {
2788
+ this.createDeviceObject("consumables", duid, state, type, states, {
2789
+ write: false,
2790
+ hasUnit: true,
2791
+ unit,
2792
+ });
2793
+ }
2794
+
2795
+ async createResetConsumables(duid, state) {
2796
+ const path = `Devices.${duid}.resetConsumables.${state}`;
2797
+ const name = this.translations[state];
2798
+
2799
+ this.setObjectNotExistsAsync(path, {
2800
+ type: "state",
2801
+ common: {
2802
+ name: name,
2803
+ type: "boolean",
2804
+ role: "value",
2805
+ read: true,
2806
+ write: true,
2807
+ def: false,
2808
+ },
2809
+ native: {},
2810
+ });
2811
+ }
2812
+
2813
+ async createCleaningRecord(duid, state, type, states, unit) {
2814
+ let start = 0;
2815
+ let end = 19;
2816
+ const robotModel = this.getProductAttribute(duid, "model");
2817
+ if (robotModel == "roborock.vacuum.a97") {
2818
+ start = 1;
2819
+ end = 20;
2820
+ }
2821
+
2822
+ for (let i = start; i <= end; i++) {
2823
+ await this.setObjectAsync(`Devices.${duid}.cleaningInfo.records.${i}`, {
2824
+ type: "folder",
2825
+ common: {
2826
+ name: `Cleaning record ${i}`,
2827
+ },
2828
+ native: {},
2829
+ });
2830
+
2831
+ this.setObjectAsync(
2832
+ `Devices.${duid}.cleaningInfo.records.${i}.${state}`,
2833
+ {
2834
+ type: "state",
2835
+ common: {
2836
+ name: this.translations[state],
2837
+ type: type,
2838
+ role: "value",
2839
+ unit: unit,
2840
+ read: true,
2841
+ write: false,
2842
+ states: states,
2843
+ },
2844
+ native: {},
2845
+ }
2846
+ );
2847
+
2848
+ await this.setObjectAsync(
2849
+ `Devices.${duid}.cleaningInfo.records.${i}.map`,
2850
+ {
2851
+ type: "folder",
2852
+ common: {
2853
+ name: "Map",
2854
+ },
2855
+ native: {},
2856
+ }
2857
+ );
2858
+ for (const name of ["mapBase64", "mapBase64Truncated", "mapData"]) {
2859
+ const objectString = `Devices.${duid}.cleaningInfo.records.${i}.map.${name}`;
2860
+ await this.createStateObjectHelper(
2861
+ objectString,
2862
+ name,
2863
+ "string",
2864
+ null,
2865
+ null,
2866
+ "value",
2867
+ true,
2868
+ false
2869
+ );
2870
+ }
2871
+ }
2872
+ }
2873
+
2874
+ async createCleaningInfo(duid, key, object) {
2875
+ const path = `Devices.${duid}.cleaningInfo.${key}`;
2876
+ const name = this.translations[object.name];
2877
+
2878
+ this.setObjectAsync(path, {
2879
+ type: "state",
2880
+ common: {
2881
+ name: name,
2882
+ type: "number",
2883
+ role: "value",
2884
+ unit: object.unit,
2885
+ read: true,
2886
+ write: false,
2887
+ },
2888
+ native: {},
2889
+ });
2890
+ }
2891
+
2892
+ async createBaseRobotObjects(duid) {
2893
+ for (const name of ["mapBase64", "mapBase64Truncated", "mapData"]) {
2894
+ const objectString = `Devices.${duid}.map.${name}`;
2895
+ await this.createStateObjectHelper(
2896
+ objectString,
2897
+ name,
2898
+ "string",
2899
+ null,
2900
+ null,
2901
+ "value",
2902
+ true,
2903
+ false
2904
+ );
2905
+ }
2906
+
2907
+ this.createNetworkInfoObjects(duid);
2908
+ }
2909
+
2910
+ async createBasicVacuumObjects(duid) {
2911
+ this.createNetworkInfoObjects(duid);
2912
+ }
2913
+
2914
+ async createBasicWashingMachineObjects(duid) {
2915
+ return this.createBasicVacuumObjects(duid);
2916
+ }
2917
+
2918
+ async createNetworkInfoObjects(duid) {
2919
+ for (const name of ["ssid", "ip", "mac", "bssid", "rssi"]) {
2920
+ const objectString = `Devices.${duid}.networkInfo.${name}`;
2921
+ const objectType = name == "rssi" ? "number" : "string";
2922
+ await this.createStateObjectHelper(
2923
+ objectString,
2924
+ name,
2925
+ objectType,
2926
+ null,
2927
+ null,
2928
+ "value",
2929
+ true,
2930
+ false
2931
+ );
2932
+ }
2933
+ }
2934
+
2935
+ async startCommand(duid, command, parameters, options = {}) {
2936
+ if (!this.isInited()) {
2937
+ this.log.warn("Adapter not inited. Command not executed.");
2938
+ return;
2939
+ }
2940
+
2941
+ // Matter/HomeKit controllers can send commands immediately after a bridge
2942
+ // restart, before Roborock login and createDevices() have populated
2943
+ // this.vacuums. Fail with a classifiable error instead of a raw TypeError
2944
+ // so callers can log a clear "still starting up" message and recover.
2945
+ if (!this.vacuums[duid]) {
2946
+ const notReadyError = new Error(
2947
+ `Roborock device ${duid} is not initialized yet; the plugin is still starting up or the device is missing from the account.`
2948
+ );
2949
+ notReadyError.code = "ROBOROCK_DEVICE_NOT_READY";
2950
+ if (options.waitForResult || options.throwOnError) {
2951
+ throw notReadyError;
2952
+ }
2953
+
2954
+ this.log.warn(notReadyError.message);
2955
+ return;
2956
+ }
2957
+
2958
+ const { waitForResult, commandOptions } = this.buildCommandOptions(options);
2959
+
2960
+ if (SIMPLE_VACUUM_COMMANDS.has(command)) {
2961
+ const commandPromise = this.vacuums[duid].command(
2962
+ duid,
2963
+ command,
2964
+ parameters,
2965
+ commandOptions
2966
+ );
2967
+ if (waitForResult) {
2968
+ await commandPromise;
2969
+ }
2970
+ } else if (command === "get_photo") {
2971
+ this.vacuums[duid].getParameter(duid, "get_photo", parameters);
2972
+ } else {
2973
+ this.log.warn(`Command ${command} not found.`);
2974
+ }
2975
+ }
2976
+
2977
+ isCleaning(state) {
2978
+ switch (state) {
2979
+ case 4: // Remote Control
2980
+ case 5: // Cleaning
2981
+ case 6: // Returning Dock
2982
+ case 7: // Manual Mode
2983
+ case 11: // Spot Cleaning
2984
+ case 15: // Docking
2985
+ case 16: // Go To
2986
+ case 17: // Zone Clean
2987
+ case 18: // Room Clean
2988
+ case 26: // Going to wash the mop
2989
+ return true;
2990
+ default:
2991
+ return false;
2992
+ }
2993
+ }
2994
+
2995
+ async getRobotVersion(duid) {
2996
+ const device = this.getAllHomeDevices().find(
2997
+ (device) => device.duid == duid
2998
+ );
2999
+ if (device) {
3000
+ return device.pv;
3001
+ }
3002
+
3003
+ return "Error in getRobotVersion. Version not found.";
3004
+ }
3005
+
3006
+ getRequestId() {
3007
+ // Wrap without handing out the same id twice: the previous version
3008
+ // returned 0 at the wrap AND on the following call, colliding two
3009
+ // pending requests every 10,000 messages.
3010
+ if (this.idCounter >= 9999) {
3011
+ this.idCounter = 0;
3012
+ }
3013
+ return this.idCounter++;
3014
+ }
3015
+
3016
+ async setupBasicObjects() {
3017
+ await this.setObjectAsync("Devices", {
3018
+ type: "folder",
3019
+ common: {
3020
+ name: "Devices",
3021
+ },
3022
+ native: {},
3023
+ });
3024
+
3025
+ await this.setObjectAsync("UserData", {
3026
+ type: "state",
3027
+ common: {
3028
+ name: "UserData string",
3029
+ type: "string",
3030
+ role: "value",
3031
+ read: true,
3032
+ write: false,
3033
+ },
3034
+ native: {},
3035
+ });
3036
+
3037
+ await this.setObjectAsync("HomeData", {
3038
+ type: "state",
3039
+ common: {
3040
+ name: "HomeData string",
3041
+ type: "string",
3042
+ role: "value",
3043
+ read: true,
3044
+ write: false,
3045
+ },
3046
+ native: {},
3047
+ });
3048
+
3049
+ await this.setObjectAsync("clientID", {
3050
+ type: "state",
3051
+ common: {
3052
+ name: "Client ID",
3053
+ type: "string",
3054
+ role: "value",
3055
+ read: true,
3056
+ write: false,
3057
+ },
3058
+ native: {},
3059
+ });
3060
+ }
3061
+
3062
+ async catchError(error, attribute, duid, model) {
3063
+ if (error) {
3064
+ const errorText = error.toString();
3065
+
3066
+ // Methods without a B01/Q7 equivalent are an expected condition on
3067
+ // those robots, not a failure; keep the log calm.
3068
+ if (
3069
+ error &&
3070
+ typeof error === "object" &&
3071
+ error.code === "B01_METHOD_UNSUPPORTED"
3072
+ ) {
3073
+ this.log.debug(errorText);
3074
+ return;
3075
+ }
3076
+
3077
+ const transientErrorKind = this.getTransientErrorKind(errorText);
3078
+ // Some callers only pass a message (no attribute/duid). Do not render
3079
+ // "Failed to execute undefined on robot undefined (unknown model)" for
3080
+ // those; log the message as-is instead.
3081
+ const hasContext = attribute !== undefined || duid !== undefined;
3082
+ const message = hasContext
3083
+ ? `Failed to execute ${attribute} on robot ${duid} (${model || "unknown model"}): ${error}`
3084
+ : String(error);
3085
+
3086
+ if (transientErrorKind) {
3087
+ const throttledWarning = this.getThrottledTransientWarning(
3088
+ transientErrorKind,
3089
+ attribute,
3090
+ duid,
3091
+ model,
3092
+ message
3093
+ );
3094
+
3095
+ if (throttledWarning) {
3096
+ this.log.warn(throttledWarning);
3097
+ } else {
3098
+ this.log.debug(
3099
+ `Suppressed transient ${transientErrorKind} warning for ${attribute} on robot ${duid} (${model || "unknown model"}): ${error}`
3100
+ );
3101
+ }
3102
+ } else {
3103
+ this.log.error(
3104
+ hasContext
3105
+ ? `Failed to execute ${attribute} on robot ${duid} (${model || "unknown model"}): ${error.stack || error}`
3106
+ : String(error.stack || error)
3107
+ );
3108
+ }
3109
+ }
3110
+ }
3111
+
3112
+ getTransientErrorKind(errorText) {
3113
+ const text = String(errorText || "");
3114
+
3115
+ if (/timed out after \d+ seconds/.test(text)) {
3116
+ if (text.includes("Local request")) {
3117
+ return "local timeout";
3118
+ }
3119
+ if (text.includes("Cloud request")) {
3120
+ return "cloud timeout";
3121
+ }
3122
+ return "timeout";
3123
+ }
3124
+
3125
+ if (text.includes("retry")) {
3126
+ return "retry";
3127
+ }
3128
+
3129
+ if (text.includes("locating")) {
3130
+ return "locating";
3131
+ }
3132
+
3133
+ return null;
3134
+ }
3135
+
3136
+ getThrottledTransientWarning(kind, attribute, duid, model, message) {
3137
+ if (this.errorLogThrottleMs <= 0) {
3138
+ return null;
3139
+ }
3140
+
3141
+ const now = this.now();
3142
+ const key = [kind, duid, model || "unknown model"].join("|");
3143
+ const previous = this.errorLogThrottle.get(key);
3144
+
3145
+ if (!previous || now - previous.lastLoggedAt >= this.errorLogThrottleMs) {
3146
+ const suppressedCount = previous?.suppressedCount || 0;
3147
+ const suppressedAttributes = previous?.suppressedAttributes || {};
3148
+ this.errorLogThrottle.set(key, {
3149
+ lastLoggedAt: now,
3150
+ suppressedCount: 0,
3151
+ suppressedAttributes: {},
3152
+ });
3153
+
3154
+ const throttleNote = ` Future transient ${kind} warnings for this robot will be logged at most once every ${this.formatThrottleDuration(this.errorLogThrottleMs)}.`;
3155
+ const summaryNote =
3156
+ suppressedCount > 0
3157
+ ? ` ${suppressedCount} similar warning(s) across ${this.formatSuppressedAttributes(suppressedAttributes)} were suppressed.`
3158
+ : "";
3159
+
3160
+ return `${message}${summaryNote}${throttleNote}`;
3161
+ }
3162
+
3163
+ previous.suppressedCount = (previous.suppressedCount || 0) + 1;
3164
+ previous.suppressedAttributes = previous.suppressedAttributes || {};
3165
+ previous.suppressedAttributes[attribute || "unknown command"] =
3166
+ (previous.suppressedAttributes[attribute || "unknown command"] || 0) + 1;
3167
+ this.errorLogThrottle.set(key, previous);
3168
+ return null;
3169
+ }
3170
+
3171
+ formatSuppressedAttributes(attributes) {
3172
+ const entries = Object.entries(attributes || {});
3173
+
3174
+ if (entries.length === 0) {
3175
+ return "this robot";
3176
+ }
3177
+
3178
+ return entries
3179
+ .map(([attribute, count]) => `${attribute} (${count})`)
3180
+ .join(", ");
3181
+ }
3182
+
3183
+ formatThrottleDuration(durationMs) {
3184
+ if (durationMs >= 60 * 1000) {
3185
+ return `${Math.round(durationMs / (60 * 1000))} minutes`;
3186
+ }
3187
+
3188
+ return `${Math.max(1, Math.round(durationMs / 1000))} seconds`;
3189
+ }
3190
+
3191
+ async app_start(duid, options) {
3192
+ await this.startCommand(duid, "app_start", null, options);
3193
+ }
3194
+
3195
+ async app_stop(duid, options) {
3196
+ await this.startCommand(duid, "app_stop", null, options);
3197
+ }
3198
+
3199
+ async app_pause(duid, options) {
3200
+ await this.startCommand(duid, "app_pause", null, options);
3201
+ }
3202
+
3203
+ async app_charge(duid, options) {
3204
+ await this.startCommand(duid, "app_charge", null, options);
3205
+ }
3206
+
3207
+ async find_me(duid, options) {
3208
+ await this.startCommand(duid, "find_me", null, options);
3209
+ }
3210
+
3211
+ async app_segment_clean_by_ids(duid, segments, options = {}) {
3212
+ await this.startCommand(
3213
+ duid,
3214
+ "app_segment_clean_by_ids",
3215
+ {
3216
+ segments,
3217
+ repeat: options.repeat,
3218
+ },
3219
+ options
3220
+ );
3221
+ }
3222
+
3223
+ async load_multi_map(duid, mapId, options = {}) {
3224
+ await this.startCommand(duid, "load_multi_map", mapId, options);
3225
+ }
3226
+
3227
+ async getServerTimers(duid) {
3228
+ if (!this.vacuums[duid]) {
3229
+ throw new Error(`Vacuum ${duid} is not initialized.`);
3230
+ }
3231
+
3232
+ return await this.vacuums[duid].getServerTimers(duid);
3233
+ }
3234
+
3235
+ async updateServerTimer(duid, timerId, enabled) {
3236
+ if (!this.vacuums[duid]) {
3237
+ throw new Error(`Vacuum ${duid} is not initialized.`);
3238
+ }
3239
+
3240
+ return await this.vacuums[duid].updateServerTimer(duid, timerId, enabled);
3241
+ }
3242
+
3243
+ async getStatus(duid, options = {}) {
3244
+ try {
3245
+ if (!this.vacuums[duid]) {
3246
+ this.log.debug(
3247
+ `Skipping status refresh for ${duid}; the Roborock device runtime is not initialized yet.`
3248
+ );
3249
+ return;
3250
+ }
3251
+
3252
+ const robotVersion = await this.getRobotVersion(duid);
3253
+ if (b01Q7Adapter.isB01Protocol(robotVersion)) {
3254
+ await this.refreshB01Status(duid, options);
3255
+ return;
3256
+ }
3257
+
3258
+ const attribute = options.force ? "force" : "state";
3259
+ const parameterOptions = options.preferCloud
3260
+ ? { preferCloud: true }
3261
+ : undefined;
3262
+ if (parameterOptions) {
3263
+ await this.vacuums[duid].getParameter(
3264
+ duid,
3265
+ "get_status",
3266
+ attribute,
3267
+ parameterOptions
3268
+ );
3269
+ return;
3270
+ }
3271
+
3272
+ await this.vacuums[duid].getParameter(duid, "get_status", attribute);
3273
+ } catch (error) {
3274
+ this.catchError(error, "getStatus", duid);
3275
+ }
3276
+ }
3277
+
3278
+ async refreshB01Status(duid, options = {}) {
3279
+ // Q7/B01 status snapshot via prop.get, mapped to v1-shaped fields and
3280
+ // dispatched on the existing live-message path so the Matter accessory
3281
+ // updates exactly like it does for classic robots.
3282
+ //
3283
+ // The 1-second poll tick relies on getParameter's internal throttling for
3284
+ // classic robots; this path must throttle itself or every tick becomes a
3285
+ // cloud request. Periodic refreshes run at most every 45s, forced
3286
+ // refreshes (post-command, robot pushes) at most every 1.5s, and
3287
+ // concurrent callers share one in-flight request.
3288
+ if (!this._b01StatusState) {
3289
+ this._b01StatusState = new Map();
3290
+ }
3291
+ let refreshState = this._b01StatusState.get(duid);
3292
+ if (!refreshState) {
3293
+ refreshState = { lastAttemptAt: 0, inflight: null, consecutiveFailures: 0 };
3294
+ this._b01StatusState.set(duid, refreshState);
3295
+ }
3296
+
3297
+ if (refreshState.inflight) {
3298
+ return refreshState.inflight;
3299
+ }
3300
+
3301
+ // Throttle on ATTEMPTS, not successes: a robot or cloud that stops
3302
+ // answering must not turn the 1-second poll tick into a retry storm.
3303
+ const minimumGapMs = options.force ? 1500 : 45000;
3304
+ if (Date.now() - refreshState.lastAttemptAt < minimumGapMs) {
3305
+ return null;
3306
+ }
3307
+ refreshState.lastAttemptAt = Date.now();
3308
+
3309
+ refreshState.inflight = (async () => {
3310
+ try {
3311
+ const data = await this.messageQueueHandler.sendRequest(
3312
+ duid,
3313
+ "get_status",
3314
+ []
3315
+ );
3316
+ const v1Status = b01Q7Adapter.mapStatusToV1(data);
3317
+
3318
+ if (refreshState.consecutiveFailures > 0) {
3319
+ this.log.info(
3320
+ `B01 status for ${duid} recovered after ${refreshState.consecutiveFailures} failed attempt(s).`
3321
+ );
3322
+ }
3323
+ refreshState.consecutiveFailures = 0;
3324
+
3325
+ if (!refreshState.firstSuccessLogged) {
3326
+ refreshState.firstSuccessLogged = true;
3327
+ this.log.info(
3328
+ `B01 status online for ${duid}: state=${v1Status.state}, battery=${v1Status.battery ?? "?"}%, charging=${v1Status.charge_status === 1 ? "yes" : "no"}.`
3329
+ );
3330
+ }
3331
+
3332
+ this.log.debug(
3333
+ `B01 status for ${duid}: ${JSON.stringify(data)} -> ${JSON.stringify(v1Status)}`
3334
+ );
3335
+
3336
+ if (this.deviceNotify !== undefined) {
3337
+ this.deviceNotify("CloudMessage", { duid, payload: [v1Status] });
3338
+ }
3339
+
3340
+ return v1Status;
3341
+ } catch (error) {
3342
+ refreshState.consecutiveFailures += 1;
3343
+ const message = error?.message || String(error);
3344
+ if (refreshState.consecutiveFailures % 10 === 0) {
3345
+ this.log.warn(
3346
+ `B01 status has failed ${refreshState.consecutiveFailures} times in a row for ${duid}. Last error: ${message}`
3347
+ );
3348
+ } else {
3349
+ this.log.debug(
3350
+ `B01 status attempt ${refreshState.consecutiveFailures} failed for ${duid}: ${message}`
3351
+ );
3352
+ }
3353
+ return null;
3354
+ } finally {
3355
+ refreshState.inflight = null;
3356
+ }
3357
+ })();
3358
+
3359
+ return refreshState.inflight;
3360
+ }
3361
+
3362
+ /**
3363
+ * B01 robots never enter the v1 getParameter flow that normally restarts
3364
+ * device intervals after an offline period, so one online flap would kill
3365
+ * their status polling forever. Called from the periodic HomeData refresh
3366
+ * as a supervisor: restarts intervals when a B01 robot is back online.
3367
+ */
3368
+ superviseB01DeviceIntervals() {
3369
+ this.startB01StatusLoop();
3370
+
3371
+ for (const duid of this.initializedVacuumDuids) {
3372
+ if (
3373
+ this.getVacuumDeviceInfo(duid, "pv") ===
3374
+ b01Q7Adapter.B01_PROTOCOL_VERSION
3375
+ ) {
3376
+ void this.manageDeviceIntervals(duid).catch(() => undefined);
3377
+ }
3378
+ }
3379
+ }
3380
+
3381
+ /**
3382
+ * Dedicated status loop for B01/Q7 robots, independent of the fragile
3383
+ * per-device v1 interval machinery. Ticks every 15 seconds and asks
3384
+ * getStatus for every initialized B01 device; the attempt throttle keeps
3385
+ * the effective cloud cadence at ~45s. Idempotent: safe to call from the
3386
+ * HomeData supervisor, which revives the loop if anything cleared it.
3387
+ */
3388
+ startB01StatusLoop() {
3389
+ if (this.b01StatusLoopHandle) {
3390
+ return;
3391
+ }
3392
+
3393
+ const hasB01Device = [...this.initializedVacuumDuids].some(
3394
+ (duid) =>
3395
+ this.getVacuumDeviceInfo(duid, "pv") ===
3396
+ b01Q7Adapter.B01_PROTOCOL_VERSION
3397
+ );
3398
+ if (!hasB01Device) {
3399
+ return;
3400
+ }
3401
+
3402
+ this.log.info(
3403
+ "Starting the dedicated B01/Q7 status loop (15s tick, ~45s effective cloud cadence)."
3404
+ );
3405
+ const pollAllB01 = (options) => {
3406
+ for (const duid of this.initializedVacuumDuids) {
3407
+ if (
3408
+ this.getVacuumDeviceInfo(duid, "pv") ===
3409
+ b01Q7Adapter.B01_PROTOCOL_VERSION
3410
+ ) {
3411
+ void this.getStatus(duid, options).catch(() => undefined);
3412
+ }
3413
+ }
3414
+ };
3415
+
3416
+ // First poll immediately: after a restart the Matter store holds the
3417
+ // registration snapshot (HomeData fallback), and the sooner the real
3418
+ // values land, the sooner controllers receive a genuine change report.
3419
+ pollAllB01({ force: true });
3420
+ this.b01StatusLoopHandle = this.setInterval(pollAllB01, 15000);
3421
+ if (typeof this.b01StatusLoopHandle?.unref === "function") {
3422
+ this.b01StatusLoopHandle.unref();
3423
+ }
3424
+ }
3425
+
3426
+ getB01RoomCache(duid) {
3427
+ const stored = this.getStateAsync("B01Rooms");
3428
+ if (stored && typeof stored.val === "string") {
3429
+ try {
3430
+ const all = JSON.parse(stored.val);
3431
+ const rooms = all?.[duid];
3432
+ return Array.isArray(rooms) ? rooms : [];
3433
+ } catch {
3434
+ return [];
3435
+ }
3436
+ }
3437
+ return [];
3438
+ }
3439
+
3440
+ async setB01RoomCache(duid, rooms) {
3441
+ const stored = this.getStateAsync("B01Rooms");
3442
+ let all = {};
3443
+ if (stored && typeof stored.val === "string") {
3444
+ try {
3445
+ all = JSON.parse(stored.val) || {};
3446
+ } catch {
3447
+ all = {};
3448
+ }
3449
+ }
3450
+ all[duid] = rooms;
3451
+ await this.setStateAsync("B01Rooms", { val: JSON.stringify(all), ack: true });
3452
+ }
3453
+
3454
+ /**
3455
+ * Fetch and cache the Q7 room list: map list -> current map id ->
3456
+ * service.upload_by_mapid -> MAP_RESPONSE (protocol 301) -> AES-ECB/zlib
3457
+ * decode -> SCMap protobuf -> {roomId, roomName}. Rooms rarely change, so
3458
+ * refreshes are throttled to once per 6 hours unless forced.
3459
+ */
3460
+ async refreshB01Rooms(duid, options = {}) {
3461
+ if (!this._b01RoomRefreshAt) {
3462
+ this._b01RoomRefreshAt = new Map();
3463
+ }
3464
+ const lastAt = this._b01RoomRefreshAt.get(duid) || 0;
3465
+ if (!options.force && Date.now() - lastAt < 6 * 60 * 60 * 1000) {
3466
+ return this.getB01RoomCache(duid);
3467
+ }
3468
+
3469
+ const mapListData = await this.messageQueueHandler.sendRequest(
3470
+ duid,
3471
+ "get_map_list",
3472
+ {}
3473
+ );
3474
+ const mapId = b01Q7Adapter.findCurrentMapId(mapListData);
3475
+ if (mapId === null) {
3476
+ this.log.debug(`No B01 map available yet for ${duid}; rooms deferred.`);
3477
+ return this.getB01RoomCache(duid);
3478
+ }
3479
+
3480
+ const rawPayload = await this.sendB01MapRequest(duid, mapId);
3481
+ const serial = this.getVacuumDeviceInfo(duid, "sn");
3482
+ const model = this.getProductAttribute(duid, "model");
3483
+ const mapKey = b01Q7Adapter.createMapKey(serial, model);
3484
+ const scMap = b01Q7Adapter.decodeMapPayload(rawPayload, mapKey);
3485
+ const rooms = b01Q7Adapter.parseRoomsFromScMap(scMap);
3486
+
3487
+ this._b01RoomRefreshAt.set(duid, Date.now());
3488
+ await this.setB01RoomCache(duid, rooms);
3489
+ this.log.info(
3490
+ `B01 rooms for ${duid}: ${rooms.length ? rooms.map((room) => `${room.roomName || "?"} (${room.roomId})`).join(", ") : "none reported"}.`
3491
+ );
3492
+ return rooms;
3493
+ }
3494
+
3495
+ async sendB01MapRequest(duid, mapId) {
3496
+ if (!this.pendingB01MapRequests) {
3497
+ this.pendingB01MapRequests = new Map();
3498
+ }
3499
+ const existing = this.pendingB01MapRequests.get(duid);
3500
+ if (existing) {
3501
+ return existing.promise;
3502
+ }
3503
+
3504
+ const messageID = b01Q7Adapter.createB01MessageId();
3505
+ const timestamp = Math.floor(Date.now() / 1000);
3506
+ const payload = await this.message.buildPayload(
3507
+ duid,
3508
+ 101,
3509
+ messageID,
3510
+ b01Q7Adapter.B01_MAP_UPLOAD_METHOD,
3511
+ { map_id: mapId },
3512
+ false,
3513
+ false
3514
+ );
3515
+ const roborockMessage = await this.message.buildRoborockMessage(
3516
+ duid,
3517
+ 101,
3518
+ timestamp,
3519
+ payload
3520
+ );
3521
+ if (!roborockMessage) {
3522
+ throw new Error(`Failed to build B01 map request for ${duid}.`);
3523
+ }
3524
+
3525
+ let entry;
3526
+ const promise = new Promise((resolve, reject) => {
3527
+ const timeout = this.setTimeout(() => {
3528
+ this.pendingB01MapRequests.delete(duid);
3529
+ reject(new Error(`B01 map request timed out after 20s for ${duid}.`));
3530
+ }, 20000);
3531
+ if (typeof timeout?.unref === "function") {
3532
+ timeout.unref();
3533
+ }
3534
+ entry = { resolve, reject, timeout };
3535
+ });
3536
+ entry.promise = promise;
3537
+ this.pendingB01MapRequests.set(duid, entry);
3538
+ this.rr_mqtt_connector.sendMessage(duid, roborockMessage);
3539
+ return promise;
3540
+ }
3541
+
3542
+ getProductData(productId) {
3543
+ const products = this.getKnownProducts();
3544
+ return products.find((product) => product.id == productId);
3545
+ }
3546
+
3547
+ getVacuumDeviceData(duid) {
3548
+ const devices = this.getAllHomeDevices();
3549
+ return devices.find((device) => device.duid == duid);
3550
+ }
3551
+
3552
+ getVacuumSchemaId(duid, code) {
3553
+ const productId = this.getVacuumDeviceInfo(duid, "productId");
3554
+ const product = this.getProductData(productId);
3555
+
3556
+ if (product) {
3557
+ const schema = product.schema;
3558
+ const schemaId = schema.find((schema) => schema.code == code);
3559
+
3560
+ if (schemaId) {
3561
+ return schemaId.id;
3562
+ }
3563
+ }
3564
+
3565
+ return null;
3566
+ }
3567
+
3568
+ getVacuumDeviceInfo(duid, property) {
3569
+ const device = this.getVacuumDeviceData(duid);
3570
+
3571
+ if (device) {
3572
+ return device[property];
3573
+ } else {
3574
+ return "";
3575
+ }
3576
+ }
3577
+
3578
+ getVacuumDeviceStatus(duid, property) {
3579
+ const propertyID = this.getVacuumSchemaId(duid, property);
3580
+
3581
+ if (propertyID == null) {
3582
+ return "";
3583
+ }
3584
+
3585
+ // The device can disappear from HomeData between the schema lookup above
3586
+ // and this read (account changes, first start before HomeData persists).
3587
+ // Status reads are used by hot Matter/HomeKit paths, so they must never
3588
+ // throw; report "no value" instead.
3589
+ const device = this.getVacuumDeviceData(duid);
3590
+ if (!device) {
3591
+ return "";
3592
+ }
3593
+
3594
+ // Q7/B01 robots report their native work-status codes in the cloud
3595
+ // deviceStatus snapshot (charging = 4, cleaning = 5/6/7, ...). Reading
3596
+ // them as v1 codes makes a charging robot look like "remote control
3597
+ // active", so translate the state attribute before anyone interprets it.
3598
+ if (
3599
+ property === "state" &&
3600
+ device.pv === b01Q7Adapter.B01_PROTOCOL_VERSION &&
3601
+ device.deviceStatus
3602
+ ) {
3603
+ const rawStatus = device.deviceStatus[propertyID];
3604
+ const translated =
3605
+ b01Q7Adapter.translateQ7WorkStatusToV1State(rawStatus);
3606
+ if (translated !== null) {
3607
+ return translated;
3608
+ }
3609
+ }
3610
+
3611
+ if (device.deviceStatus) {
3612
+ if (device.deviceStatus[propertyID] != undefined) {
3613
+ return device.deviceStatus[propertyID];
3614
+ }
3615
+
3616
+ if (device.deviceStatus[property] != undefined) {
3617
+ return device.deviceStatus[property];
3618
+ }
3619
+ }
3620
+
3621
+ return "";
3622
+ }
3623
+
3624
+ getVacuumList() {
3625
+ return this.getAllHomeDevices();
3626
+ }
3627
+
3628
+ setDeviceNotify(callback) {
3629
+ this.deviceNotify = callback;
3630
+ }
3631
+ }
3632
+
3633
+ module.exports = { Roborock };
3634
+
3635
+ ////////////////////////////////////////////////////////////////////////////////////////////////////