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,992 @@
1
+ "use strict";
2
+
3
+ const rrMessage = require("./message").message;
4
+ const RRMapParser = require("./RRMapParser");
5
+ const fs = require("fs");
6
+ const zlib = require("zlib");
7
+
8
+ const mappedCleanSummary = {
9
+ 0: "clean_time",
10
+ 1: "clean_area",
11
+ 2: "clean_count",
12
+ 3: "records",
13
+ };
14
+
15
+ const mappedCleaningRecordAttribute = {
16
+ 0: "begin",
17
+ 1: "end",
18
+ 2: "duration",
19
+ 3: "area",
20
+ 4: "error",
21
+ 5: "complete",
22
+ 6: "start_type",
23
+ 7: "clean_type",
24
+ 8: "finish_reason",
25
+ 9: "dust_collection_status",
26
+ };
27
+
28
+ class vacuum {
29
+ constructor(adapter, robotModel) {
30
+ this.adapter = adapter;
31
+
32
+ this.adapter.log.debug(`Robot key: ${robotModel}`);
33
+ this.robotModel = robotModel;
34
+
35
+ this.message = new rrMessage(this.adapter);
36
+
37
+ this.mapParser = new RRMapParser(this.adapter);
38
+
39
+ this.parameterFolders = {
40
+ get_mop_mode: "deviceStatus",
41
+ get_water_box_mode: "deviceStatus",
42
+ get_water_box_custom_mode: "deviceStatus",
43
+ get_network_info: "networkInfo",
44
+ get_consumable: "consumables",
45
+ get_fw_features: "firmwareFeatures",
46
+ get_carpet_mode: "deviceStatus",
47
+ get_carpet_clean_mode: "deviceStatus",
48
+ get_carpet_cleaning_mode: "deviceStatus",
49
+ };
50
+ }
51
+
52
+ async updateDiagnosticSnapshot(duid, key, payload) {
53
+ if (typeof this.adapter.updateRoborockDiagnostics !== "function") {
54
+ return;
55
+ }
56
+
57
+ await this.adapter.updateRoborockDiagnostics(duid, key, {
58
+ capturedAt: new Date().toISOString(),
59
+ payload,
60
+ });
61
+ }
62
+
63
+ async command(duid, parameter, value, options = {}) {
64
+ try {
65
+ const requestOptions = {};
66
+ if (options.preferCloud) {
67
+ requestOptions.preferCloud = true;
68
+ }
69
+ if (options.preferLocal) {
70
+ requestOptions.preferLocal = true;
71
+ }
72
+ if (options.allowOfflineCloudSend) {
73
+ requestOptions.allowOfflineCloudSend = true;
74
+ }
75
+ if (
76
+ typeof options.requestTimeoutMs === "number" &&
77
+ Number.isFinite(options.requestTimeoutMs) &&
78
+ options.requestTimeoutMs > 0
79
+ ) {
80
+ requestOptions.requestTimeoutMs = options.requestTimeoutMs;
81
+ }
82
+ const hasRequestOptions = Object.keys(requestOptions).length > 0;
83
+ const sendCommandRequest = (method, params) =>
84
+ hasRequestOptions
85
+ ? this.adapter.messageQueueHandler.sendRequest(
86
+ duid,
87
+ method,
88
+ params,
89
+ false,
90
+ false,
91
+ requestOptions
92
+ )
93
+ : this.adapter.messageQueueHandler.sendRequest(duid, method, params);
94
+
95
+ switch (parameter) {
96
+ case "app_segment_clean": {
97
+ this.adapter.log.debug("Start room cleaning");
98
+
99
+ const roomList = {};
100
+ roomList.segments = [];
101
+ const roomFloor = await this.adapter.getStateAsync(
102
+ `Devices.${duid}.deviceStatus.map_status`
103
+ );
104
+ const mappedRoomList = await sendCommandRequest(
105
+ "get_room_mapping",
106
+ []
107
+ );
108
+
109
+ if (mappedRoomList) {
110
+ const roomStates = await Promise.all(
111
+ Object.keys(mappedRoomList).map((mappedRoom) =>
112
+ this.adapter.getStateAsync(
113
+ `Devices.${duid}.floors.${roomFloor.val}.${mappedRoomList[mappedRoom][0]}`
114
+ )
115
+ )
116
+ );
117
+
118
+ Object.keys(mappedRoomList).forEach((mappedRoom, index) => {
119
+ if (roomStates[index].val) {
120
+ roomList.segments.push(mappedRoomList[mappedRoom][0]);
121
+ }
122
+ });
123
+ }
124
+
125
+ const cleanCount = await this.adapter.getStateAsync(
126
+ `Devices.${duid}.floors.cleanCount`
127
+ );
128
+ roomList["repeat"] = cleanCount.val;
129
+
130
+ const result = await sendCommandRequest("app_segment_clean", [
131
+ roomList,
132
+ ]);
133
+ this.adapter.log.debug(
134
+ `app_segment_clean with roomIDs: ${JSON.stringify(roomList)} result: ${result}`
135
+ );
136
+ this.adapter.setStateAsync(`Devices.${duid}.floors.cleanCount`, {
137
+ val: 1,
138
+ ack: true,
139
+ });
140
+
141
+ break;
142
+ }
143
+ case "app_segment_clean_by_ids": {
144
+ const requestedSegments =
145
+ value && Array.isArray(value.segments) ? value.segments : [];
146
+ const segments = Array.from(
147
+ new Set(
148
+ requestedSegments
149
+ .map((segment) => Number(segment))
150
+ .filter((segment) => Number.isInteger(segment) && segment >= 0)
151
+ )
152
+ );
153
+ const repeat = Number(value && value.repeat);
154
+ const roomList = {
155
+ segments,
156
+ repeat: Number.isInteger(repeat) && repeat > 0 ? repeat : 1,
157
+ };
158
+
159
+ if (roomList.segments.length === 0) {
160
+ this.adapter.log.warn(
161
+ `No room segments supplied for app_segment_clean_by_ids on ${duid}.`
162
+ );
163
+ break;
164
+ }
165
+
166
+ const result = await sendCommandRequest("app_segment_clean", [
167
+ roomList,
168
+ ]);
169
+ this.adapter.log.debug(
170
+ `app_segment_clean_by_ids with roomIDs: ${JSON.stringify(roomList)} result: ${result}`
171
+ );
172
+
173
+ break;
174
+ }
175
+ case "reset_consumable":
176
+ await sendCommandRequest(parameter, [value]);
177
+ this.adapter.log.info(`Consumable ${parameter} successfully reset.`);
178
+
179
+ break;
180
+
181
+ case "app_set_dryer_status": {
182
+ const result = await sendCommandRequest(parameter, JSON.parse(value));
183
+ this.adapter.log.debug(`Command: ${parameter} result: ${result}`);
184
+
185
+ break;
186
+ }
187
+ case "app_goto_target":
188
+ case "app_zoned_clean": {
189
+ const result = await sendCommandRequest(parameter, value);
190
+ this.adapter.log.debug(
191
+ `Command: ${parameter} with value: ${JSON.stringify(value)} result: ${result}`
192
+ );
193
+
194
+ break;
195
+ }
196
+ case "load_multi_map": {
197
+ const mapId = Number(value);
198
+ if (!Number.isInteger(mapId) || mapId < 0) {
199
+ this.adapter.log.warn(
200
+ `Invalid map id '${value}' supplied for load_multi_map on ${duid}.`
201
+ );
202
+ break;
203
+ }
204
+
205
+ const result = await sendCommandRequest(parameter, [mapId]);
206
+ this.adapter.log.debug(
207
+ `Command: ${parameter} with value: ${mapId} result: ${result}`
208
+ );
209
+ return await this.getParameter(duid, "get_room_mapping");
210
+ }
211
+ case "set_water_box_distance_off": {
212
+ const mappedValue = ((value - 1) / (30 - 1)) * (60 - 205) + 205;
213
+ const parameterValue = { distance_off: mappedValue };
214
+
215
+ const result = await sendCommandRequest(parameter, parameterValue);
216
+ this.adapter.log.debug(
217
+ `Command: ${parameter} with value: ${JSON.stringify(parameterValue)} result: ${result}`
218
+ );
219
+ break;
220
+ }
221
+ default:
222
+ if (value && typeof value !== "boolean") {
223
+ const valueType = typeof value;
224
+
225
+ if (valueType === "string") {
226
+ value = await JSON.parse(value);
227
+ } else if (valueType === "number") {
228
+ value = [value];
229
+ }
230
+
231
+ // await is important here!!! Wait for the command to finish before sending the request to update deviceConfig!!!
232
+ const result = await sendCommandRequest(parameter, value);
233
+ this.adapter.log.debug(
234
+ `Command: ${parameter} with value: ${JSON.stringify(value)} result: ${result}`
235
+ );
236
+
237
+ // this is needed to update the states instantly after sending a command
238
+ const getCommand = parameter.replace("set", "get");
239
+ await this.getParameter(duid, getCommand);
240
+ return result;
241
+ } else {
242
+ const result = await sendCommandRequest(parameter, []);
243
+ this.adapter.log.debug(`Command: ${parameter} result: ${result}`);
244
+ return result;
245
+ }
246
+ }
247
+ } catch (error) {
248
+ this.adapter.catchError(error, parameter, duid, this.robotModel);
249
+ if (options.throwOnError) {
250
+ throw error;
251
+ }
252
+ }
253
+ }
254
+
255
+ async getServerTimers(duid) {
256
+ try {
257
+ return await this.adapter.messageQueueHandler.sendRequest(
258
+ duid,
259
+ "get_server_timer",
260
+ []
261
+ );
262
+ } catch (error) {
263
+ this.adapter.catchError(error, "get_server_timer", duid, this.robotModel);
264
+ throw error;
265
+ }
266
+ }
267
+
268
+ async updateServerTimer(duid, timerId, enabled) {
269
+ try {
270
+ return await this.adapter.messageQueueHandler.sendRequest(
271
+ duid,
272
+ "upd_server_timer",
273
+ [timerId, enabled ? "on" : "off"]
274
+ );
275
+ } catch (error) {
276
+ this.adapter.catchError(error, "upd_server_timer", duid, this.robotModel);
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ async getParameter(duid, parameter, attribute, options = {}) {
282
+ let mode;
283
+
284
+ try {
285
+ if (parameter == "get_network_info") {
286
+ mode = parameter;
287
+ const networkInfo = await this.adapter.messageQueueHandler.sendRequest(
288
+ duid,
289
+ parameter,
290
+ []
291
+ );
292
+
293
+ for (const attribute in networkInfo) {
294
+ if (
295
+ attribute == "ip" &&
296
+ !this.adapter.isCloudOnlyModeEnabled?.() &&
297
+ !(await this.adapter.isRemoteDevice(duid))
298
+ ) {
299
+ this.adapter.localDevices[duid] = networkInfo[attribute];
300
+ }
301
+ this.adapter.setStateAsync(
302
+ `Devices.${duid}.networkInfo.${attribute}`,
303
+ { val: networkInfo[attribute], ack: true }
304
+ );
305
+ }
306
+ } else if (parameter == "get_consumable") {
307
+ const consumables = (
308
+ await this.adapter.messageQueueHandler.sendRequest(
309
+ duid,
310
+ "get_consumable",
311
+ []
312
+ )
313
+ )[0];
314
+
315
+ for (const consumable in consumables) {
316
+ const divider =
317
+ this.adapter.vacuums[duid].features.getConsumablesDivider(
318
+ consumable
319
+ );
320
+ if (divider) {
321
+ const consumable_val = divider
322
+ ? Math.round(consumables[consumable] / divider)
323
+ : consumables[consumable];
324
+
325
+ this.adapter.setStateAsync(
326
+ `Devices.${duid}.consumables.${consumable}`,
327
+ { val: consumable_val, ack: true }
328
+ );
329
+ }
330
+ }
331
+ } else if (parameter == "get_status") {
332
+ const now = new Date();
333
+ const seconds = now.getSeconds();
334
+ const force = attribute == "force";
335
+
336
+ if (
337
+ force ||
338
+ this.adapter.socket ||
339
+ seconds % this.adapter.config.updateInterval == 0
340
+ ) {
341
+ // only send status every minute or if websocket is connected
342
+
343
+ // const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(duid, "get_status", []);
344
+ const requestOptions = options.preferCloud
345
+ ? { preferCloud: true }
346
+ : {};
347
+ const deviceStatus =
348
+ await this.adapter.messageQueueHandler.sendRequest(
349
+ duid,
350
+ "get_prop",
351
+ ["get_status"],
352
+ false,
353
+ false,
354
+ requestOptions
355
+ );
356
+
357
+ await this.updateDiagnosticSnapshot(duid, "lastStatus", {
358
+ method: "get_status",
359
+ status: deviceStatus[0] || null,
360
+ });
361
+
362
+ for (const attribute in deviceStatus[0]) {
363
+ const isCleaning = this.adapter.isCleaning(
364
+ deviceStatus[0]["state"]
365
+ );
366
+
367
+ if (
368
+ !(await this.adapter.getObjectAsync(
369
+ `Devices.${duid}.deviceStatus.${attribute}`
370
+ ))
371
+ ) {
372
+ const isKnownStatusAttribute =
373
+ typeof this.adapter.vacuums[duid].features
374
+ .hasDeviceStatusAttribute === "function" &&
375
+ this.adapter.vacuums[duid].features.hasDeviceStatusAttribute(
376
+ attribute
377
+ );
378
+
379
+ if (isKnownStatusAttribute) {
380
+ this.adapter.log.debug(
381
+ `Skipping known get_status attribute without a Homebridge state object: ${attribute}. Model: ${this.robotModel}`
382
+ );
383
+ } else {
384
+ this.adapter.log.warn(
385
+ `Unsupported attribute: ${attribute} of get_status with value ${deviceStatus[0][attribute]}. Please contact the dev to add the newly found attribute of your robot. Model: ${this.robotModel}`
386
+ );
387
+ }
388
+ continue; // skip unsupported attributes
389
+ }
390
+
391
+ const divider =
392
+ this.adapter.vacuums[duid].features.getStatusDivider(attribute);
393
+ if (divider) {
394
+ deviceStatus[0][attribute] = Math.round(
395
+ deviceStatus[0][attribute] / divider
396
+ );
397
+ }
398
+
399
+ if (typeof deviceStatus[0][attribute] == "object") {
400
+ deviceStatus[0][attribute] = JSON.stringify(
401
+ deviceStatus[0][attribute]
402
+ );
403
+ }
404
+
405
+ switch (attribute) {
406
+ case "dock_type":
407
+ this.adapter.vacuums[duid].features.processDockType(attribute);
408
+ break;
409
+ case "dss":
410
+ await this.adapter.createDockingStationObject(duid);
411
+ const dockingStationStatus =
412
+ await this.parseDockingStationStatus(
413
+ deviceStatus[0][attribute]
414
+ );
415
+
416
+ for (const state in dockingStationStatus) {
417
+ this.adapter.setStateAsync(
418
+ `Devices.${duid}.dockingStationStatus.${state}`,
419
+ { val: parseInt(dockingStationStatus[state]), ack: true }
420
+ );
421
+ }
422
+ break;
423
+ case "map_status": {
424
+ deviceStatus[0][attribute] =
425
+ deviceStatus[0][attribute] >> 2 ?? -1; // to get the currently selected map perform bitwise right shift
426
+
427
+ if (isCleaning) {
428
+ this.adapter.startMapUpdater(duid);
429
+ } else if (!isCleaning) {
430
+ this.adapter.stopMapUpdater(duid);
431
+ } else {
432
+ const mapCount = await this.adapter.getStateAsync(
433
+ `Devices.${duid}.floors.multi_map_count`
434
+ );
435
+
436
+ // don't process load_multi_map for single level configuration
437
+ if (mapCount) {
438
+ // sometimes mapCount is not available shortly after first start of adapter
439
+ if (mapCount.val > 1) {
440
+ const currentMap = deviceStatus[0][attribute];
441
+ const mapFromCommand = await this.adapter.getState(
442
+ `Devices.${duid}.commands.load_multi_map`
443
+ );
444
+
445
+ if (mapFromCommand && mapFromCommand.val != currentMap) {
446
+ await this.adapter.setStateAsync(
447
+ `Devices.${duid}.commands.load_multi_map`,
448
+ currentMap,
449
+ true
450
+ );
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ break;
457
+ }
458
+ case "state": {
459
+ if (this.adapter.socket) {
460
+ const sendValue = {
461
+ duid: duid,
462
+ command: "get_status",
463
+ parameters: { isCleaning: isCleaning },
464
+ };
465
+ this.adapter.socket.send(JSON.stringify(sendValue));
466
+ }
467
+
468
+ break;
469
+ }
470
+ case "last_clean_t":
471
+ deviceStatus[0][attribute] = new Date(
472
+ deviceStatus[0][attribute]
473
+ ).toString();
474
+
475
+ break;
476
+ }
477
+ this.adapter.setStateChangedAsync(
478
+ `Devices.${duid}.deviceStatus.${attribute}`,
479
+ { val: deviceStatus[0][attribute], ack: true }
480
+ );
481
+ }
482
+ this.adapter.manageDeviceIntervals(duid);
483
+ }
484
+ } else if (parameter == "get_room_mapping") {
485
+ const deviceStatus = await this.adapter.messageQueueHandler.sendRequest(
486
+ duid,
487
+ "get_status",
488
+ []
489
+ );
490
+ const mapStatus = Array.isArray(deviceStatus)
491
+ ? deviceStatus[0]?.["map_status"]
492
+ : undefined;
493
+ // to get the currently selected map perform bitwise right shift
494
+ const roomFloor = typeof mapStatus === "number" ? mapStatus >> 2 : -1;
495
+ const mappedRooms = await this.adapter.messageQueueHandler.sendRequest(
496
+ duid,
497
+ "get_room_mapping",
498
+ []
499
+ );
500
+ if (typeof this.adapter.updateRoomMappingCache === "function") {
501
+ this.adapter.updateRoomMappingCache(duid, roomFloor, mappedRooms);
502
+ }
503
+
504
+ // if no rooms have been named, processing them can't work
505
+ if (!Array.isArray(mappedRooms) || mappedRooms.length < 1) {
506
+ this.adapter.log.info(
507
+ `No room mappings returned for ${duid}. Room-based controls will stay unavailable until the Roborock app exposes named rooms.`
508
+ );
509
+ } else {
510
+ let unnamedRooms = 0;
511
+ for (const mappedRoom of mappedRooms) {
512
+ const roomID = mappedRoom[1];
513
+ const roomName = this.adapter.roomIDs[roomID] || `Room ${roomID}`;
514
+
515
+ if (!this.adapter.roomIDs[roomID]) {
516
+ unnamedRooms++;
517
+ }
518
+
519
+ this.adapter.log.debug(
520
+ `Mapped room matched: ${roomID} with name: ${roomName}`
521
+ );
522
+ const objectString = `Devices.${duid}.floors.${roomFloor}.${mappedRoom[0]}`;
523
+ await this.adapter.createStateObjectHelper(
524
+ objectString,
525
+ roomName,
526
+ "boolean",
527
+ null,
528
+ true,
529
+ "value",
530
+ true,
531
+ true
532
+ );
533
+ }
534
+
535
+ if (unnamedRooms > 0) {
536
+ this.adapter.log.info(
537
+ `${unnamedRooms} room(s) for ${duid} were missing names from HomeData. Using fallback labels like 'Room <id>' until the Roborock app syncs names.`
538
+ );
539
+ }
540
+ }
541
+
542
+ const objectString = `Devices.${duid}.floors.cleanCount`;
543
+ await this.adapter.createStateObjectHelper(
544
+ objectString,
545
+ "Clean count",
546
+ "number",
547
+ null,
548
+ 1,
549
+ "value",
550
+ true,
551
+ true
552
+ );
553
+
554
+ return mappedRooms;
555
+ } else if (parameter == "get_multi_maps_list") {
556
+ const mapList = await this.adapter.messageQueueHandler.sendRequest(
557
+ duid,
558
+ "get_multi_maps_list",
559
+ []
560
+ );
561
+ const mapInfo = mapList[0]?.map_info || [];
562
+ const maps = {};
563
+
564
+ if (typeof this.adapter.updateMapListCache === "function") {
565
+ this.adapter.updateMapListCache(duid, mapInfo);
566
+ }
567
+
568
+ // Set states for numeric parameters
569
+ for (const mapParameter in mapList[0] || {}) {
570
+ if (typeof mapList[0][mapParameter] === "number") {
571
+ const statePath = `Devices.${duid}.floors.${mapParameter}`;
572
+ this.adapter.setStateAsync(statePath, {
573
+ val: mapList[0][mapParameter],
574
+ ack: true,
575
+ });
576
+ }
577
+ }
578
+
579
+ // Create map folders
580
+ for (const map in mapInfo) {
581
+ const roomFloor = mapInfo[map]["mapFlag"];
582
+ const mapName = mapInfo[map]["name"];
583
+ maps[roomFloor] = mapName;
584
+
585
+ const objectPath = `Devices.${duid}.floors.${roomFloor}`;
586
+ this.adapter.setObjectAsync(objectPath, {
587
+ type: "folder",
588
+ common: {
589
+ name: mapName,
590
+ },
591
+ native: {},
592
+ });
593
+ }
594
+
595
+ // Handle the load_multi_map command
596
+ const commandPath = `Devices.${duid}.commands.load_multi_map`;
597
+ if ((mapList[0]?.max_multi_map || 0) > 1) {
598
+ await this.adapter.createStateObjectHelper(
599
+ commandPath,
600
+ "Load map",
601
+ "number",
602
+ null,
603
+ 0,
604
+ "value",
605
+ true,
606
+ true,
607
+ maps
608
+ );
609
+ } else {
610
+ this.adapter.delObjectAsync(commandPath);
611
+ }
612
+
613
+ return mapInfo;
614
+ } else if (parameter == "get_fw_features") {
615
+ const firmwareFeatures =
616
+ await this.adapter.messageQueueHandler.sendRequest(
617
+ duid,
618
+ parameter,
619
+ []
620
+ );
621
+ for (const firmwareFeature in firmwareFeatures) {
622
+ const featureID = firmwareFeatures[firmwareFeature];
623
+
624
+ const objectString = `Devices.${duid}.firmwareFeatures.${firmwareFeature}`;
625
+ await this.adapter.createStateObjectHelper(
626
+ objectString,
627
+ featureID.toString(),
628
+ "string",
629
+ null,
630
+ null,
631
+ "value",
632
+ true,
633
+ false
634
+ );
635
+
636
+ const featureName =
637
+ this.adapter.vacuums[duid].features.getFirmwareFeature(featureID);
638
+
639
+ // this dynamically processes robot features by ID if they are supported
640
+ if (
641
+ typeof this.adapter.vacuums[duid].features[featureName] ===
642
+ "function"
643
+ ) {
644
+ this.adapter.vacuums[duid].features[featureName]();
645
+ }
646
+
647
+ this.adapter.setStateAsync(objectString, {
648
+ val: featureName,
649
+ ack: true,
650
+ });
651
+ }
652
+ } else if (parameter == "get_server_timer") {
653
+ if (this.adapter.config.debug) {
654
+ const serverTimers =
655
+ await this.adapter.messageQueueHandler.sendRequest(
656
+ duid,
657
+ parameter,
658
+ []
659
+ );
660
+ await this.updateDiagnosticSnapshot(duid, "lastServerTimer", {
661
+ method: parameter,
662
+ response: serverTimers,
663
+ });
664
+ this.adapter.log.debug(
665
+ `Roborock ${parameter} diagnostic for ${duid}: ${JSON.stringify(this.adapter.compactDiagnosticPayload(serverTimers))}`
666
+ );
667
+ }
668
+ } else if (parameter == "get_timer") {
669
+ if (this.adapter.config.debug) {
670
+ const timers = await this.adapter.messageQueueHandler.sendRequest(
671
+ duid,
672
+ parameter,
673
+ []
674
+ );
675
+ await this.updateDiagnosticSnapshot(duid, "lastTimer", {
676
+ method: parameter,
677
+ response: timers,
678
+ });
679
+ this.adapter.log.debug(
680
+ `Roborock ${parameter} diagnostic for ${duid}: ${JSON.stringify(this.adapter.compactDiagnosticPayload(timers))}`
681
+ );
682
+ }
683
+ } else if (parameter == "get_photo") {
684
+ const photoresponse =
685
+ await this.adapter.messageQueueHandler.sendRequest(
686
+ duid,
687
+ "get_photo",
688
+ attribute,
689
+ true,
690
+ true
691
+ );
692
+
693
+ if (this.isGZIP(photoresponse)) {
694
+ this.adapter.log.debug(`gzipped photo found.`);
695
+ this.adapter.log.debug(JSON.stringify(photoresponse));
696
+
697
+ this.unzipBuffer(photoresponse, (error, photoData) => {
698
+ if (error) {
699
+ this.adapter.catchError(
700
+ error,
701
+ "get_photo",
702
+ duid,
703
+ this.robotModel
704
+ );
705
+
706
+ if (
707
+ this.adapter.supportsFeature &&
708
+ this.adapter.supportsFeature("PLUGINS")
709
+ ) {
710
+ if (this.adapter.sentryInstance) {
711
+ this.adapter.sentryInstance
712
+ .getSentryObject()
713
+ .captureException(
714
+ `Failed to extract gzip: ${JSON.stringify(error)}`
715
+ );
716
+ }
717
+ }
718
+ } else {
719
+ const extractedPhoto = this.extractPhoto(photoData);
720
+
721
+ if (extractedPhoto) {
722
+ const photo = {};
723
+ photo.duid = duid;
724
+ photo.command = "get_photo";
725
+ photo.image = `data:image/jpeg;base64,${extractedPhoto.toString("base64")}`;
726
+
727
+ if (this.adapter.socket) {
728
+ this.adapter.socket.send(JSON.stringify(photo));
729
+ }
730
+ }
731
+ }
732
+ });
733
+ }
734
+ } else if (
735
+ parameter == "get_dust_collection_switch_status" ||
736
+ parameter == "get_wash_towel_mode" ||
737
+ parameter == "get_smart_wash_params" ||
738
+ parameter == "get_dust_collection_mode"
739
+ ) {
740
+ const attribute_val = JSON.stringify(
741
+ await this.adapter.messageQueueHandler.sendRequest(
742
+ duid,
743
+ parameter,
744
+ {}
745
+ )
746
+ );
747
+ this.adapter.setStateAsync(
748
+ `Devices.${duid}.commands.${parameter.replace("get", "set")}`,
749
+ { val: attribute_val, ack: true }
750
+ );
751
+ } else if (parameter == "app_get_dryer_setting") {
752
+ const attribute_val =
753
+ await this.adapter.messageQueueHandler.sendRequest(
754
+ duid,
755
+ parameter,
756
+ {}
757
+ );
758
+ const actualVal = JSON.stringify({
759
+ on: { dry_time: attribute_val.on.dry_time },
760
+ status: attribute_val.status,
761
+ });
762
+ this.adapter.setStateAsync(
763
+ `Devices.${duid}.commands.${parameter.replace("get", "set")}`,
764
+ { val: actualVal, ack: true }
765
+ );
766
+ } else if (this.parameterFolders[parameter]) {
767
+ mode = parameter.substring(4);
768
+ const attribute_val =
769
+ await this.adapter.messageQueueHandler.sendRequest(
770
+ duid,
771
+ parameter,
772
+ []
773
+ );
774
+
775
+ if (typeof attribute_val[0] == "object") {
776
+ attribute_val[0] = JSON.stringify(attribute_val[0]);
777
+ }
778
+ const targetFolder = this.parameterFolders[parameter];
779
+ this.adapter.setStateAsync(`Devices.${duid}.${targetFolder}.${mode}`, {
780
+ val: attribute_val[0],
781
+ ack: true,
782
+ });
783
+ } else {
784
+ // unknown parameter
785
+ const unknown_parameter_val =
786
+ await this.adapter.messageQueueHandler.sendRequest(
787
+ duid,
788
+ parameter,
789
+ []
790
+ );
791
+
792
+ // this.adapter.setStateAsync("Devices." + duid + "." + targetFolder + "." + mode, { val: attribute_val[0], ack: true });
793
+ if (typeof unknown_parameter_val == "object") {
794
+ if (typeof unknown_parameter_val[0] != "number") {
795
+ this.adapter.catchError(
796
+ `Unknown parameter: ${JSON.stringify(unknown_parameter_val)}`,
797
+ parameter,
798
+ duid,
799
+ this.robotModel
800
+ );
801
+ }
802
+ } else {
803
+ this.adapter.catchError(
804
+ `Unknown parameter: ${unknown_parameter_val}`,
805
+ parameter,
806
+ duid,
807
+ this.robotModel
808
+ );
809
+ }
810
+ }
811
+ } catch (error) {
812
+ this.adapter.catchError(error, parameter, duid, this.robotModel);
813
+ }
814
+ }
815
+
816
+ async setUpObjects(duid) {
817
+ await this.adapter.setObjectAsync("Devices." + duid, {
818
+ type: "device",
819
+ common: {
820
+ name: this.adapter.vacuums[duid].name,
821
+ statusStates: {
822
+ onlineId: `${this.adapter.name}.${this.adapter.instance}.Devices.${duid}.deviceInfo.online`,
823
+ },
824
+ },
825
+ native: {},
826
+ });
827
+ }
828
+
829
+ async parseDockingStationStatus(dss) {
830
+ return {
831
+ cleanFluidStatus: (dss >> 10) & 0b11,
832
+ waterBoxFilterStatus: (dss >> 8) & 0b11,
833
+ dustBagStatus: (dss >> 6) & 0b11,
834
+ dirtyWaterBoxStatus: (dss >> 4) & 0b11,
835
+ clearWaterBoxStatus: (dss >> 2) & 0b11,
836
+ isUpdownWaterReady: dss & 0b11,
837
+ };
838
+ }
839
+ async getCleanSummary(duid) {
840
+ try {
841
+ const cleaningAttributes =
842
+ await this.adapter.messageQueueHandler.sendRequest(
843
+ duid,
844
+ "get_clean_summary",
845
+ []
846
+ );
847
+
848
+ for (const cleaningAttribute in cleaningAttributes) {
849
+ const mappedAttribute =
850
+ mappedCleanSummary[cleaningAttribute] || cleaningAttribute;
851
+
852
+ if (
853
+ ["clean_time", "clean_area", "clean_count"].includes(mappedAttribute)
854
+ ) {
855
+ await this.adapter.setStateAsync(
856
+ `Devices.${duid}.cleaningInfo.${cleaningAttribute}`,
857
+ {
858
+ val: this.calculateCleaningValue(
859
+ mappedAttribute,
860
+ cleaningAttributes[cleaningAttribute]
861
+ ),
862
+ ack: true,
863
+ }
864
+ );
865
+ } else if (mappedAttribute == "records") {
866
+ const cleaningRecordsJSON = [];
867
+ const cleaningRecordIndexes = Object.keys(
868
+ cleaningAttributes[cleaningAttribute]
869
+ );
870
+
871
+ const allCleaningRecordAttributes = await Promise.all(
872
+ cleaningRecordIndexes.map((cleaningRecord) => {
873
+ const cleaningRecordID =
874
+ cleaningAttributes[cleaningAttribute][cleaningRecord];
875
+ return this.adapter.messageQueueHandler
876
+ .sendRequest(duid, "get_clean_record", [cleaningRecordID])
877
+ .then((result) => result[0]);
878
+ })
879
+ );
880
+
881
+ for (let i = 0; i < cleaningRecordIndexes.length; i++) {
882
+ const cleaningRecord = cleaningRecordIndexes[i];
883
+ const cleaningRecordAttributes = allCleaningRecordAttributes[i];
884
+
885
+ cleaningRecordsJSON[cleaningRecord] = cleaningRecordAttributes;
886
+
887
+ for (const cleaningRecordAttribute in cleaningRecordAttributes) {
888
+ const mappedRecordAttribute =
889
+ mappedCleaningRecordAttribute[cleaningRecordAttribute] ||
890
+ cleaningRecordAttribute;
891
+ await this.adapter.setStateAsync(
892
+ `Devices.${duid}.cleaningInfo.records.${cleaningRecord}.${mappedRecordAttribute}`,
893
+ {
894
+ val: this.calculateRecordValue(
895
+ mappedRecordAttribute,
896
+ cleaningRecordAttributes[cleaningRecordAttribute]
897
+ ),
898
+ ack: true,
899
+ }
900
+ );
901
+ }
902
+ }
903
+
904
+ const objectString = `Devices.${duid}.cleaningInfo.JSON`;
905
+ await this.adapter.createStateObjectHelper(
906
+ objectString,
907
+ "cleaningInfoJSON",
908
+ "string",
909
+ null,
910
+ null,
911
+ "json",
912
+ true,
913
+ false
914
+ );
915
+ this.adapter.setStateAsync(`Devices.${duid}.cleaningInfo.JSON`, {
916
+ val: JSON.stringify(cleaningRecordsJSON),
917
+ ack: true,
918
+ });
919
+ }
920
+ }
921
+ } catch (error) {
922
+ this.adapter.catchError(
923
+ error,
924
+ "get_clean_summary",
925
+ duid,
926
+ this.robotModel
927
+ );
928
+ }
929
+ }
930
+
931
+ calculateCleaningValue(attribute, value) {
932
+ switch (attribute) {
933
+ case "clean_time":
934
+ return Math.round(value / 60 / 60);
935
+ case "clean_area":
936
+ return Math.round(value / 1000 / 1000);
937
+ default:
938
+ return value;
939
+ }
940
+ }
941
+
942
+ calculateRecordValue(attribute, value) {
943
+ switch (attribute) {
944
+ case "begin":
945
+ case "end":
946
+ return new Date(value * 1000).toString();
947
+ case "duration":
948
+ return Math.round(value / 60);
949
+ case "area":
950
+ return Math.round(value / 1000 / 1000);
951
+ default:
952
+ return value;
953
+ }
954
+ }
955
+
956
+ unzipBuffer(buffer, callback) {
957
+ zlib.gunzip(buffer, callback);
958
+ }
959
+
960
+ isGZIP(buffer) {
961
+ return buffer.length >= 2 && buffer[0] == 31 && buffer[1] == 139;
962
+ }
963
+ extractPhoto(buffer) {
964
+ // Verify that the buffer is long enough to hold the header
965
+ if (buffer.length < 10) {
966
+ return false;
967
+ }
968
+
969
+ // Check the signature
970
+ if (
971
+ buffer[26] == 74 &&
972
+ buffer[27] == 70 &&
973
+ buffer[28] == 73 &&
974
+ buffer[29] == 70
975
+ ) {
976
+ return buffer.slice(20);
977
+ } else if (
978
+ buffer[42] == 74 &&
979
+ buffer[43] == 70 &&
980
+ buffer[44] == 73 &&
981
+ buffer[45] == 70
982
+ ) {
983
+ return buffer.slice(36);
984
+ }
985
+
986
+ return false;
987
+ }
988
+ }
989
+
990
+ module.exports = {
991
+ vacuum,
992
+ };