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.
- package/.gitattributes +13 -0
- package/CHANGELOG.md +469 -0
- package/LICENSE +23 -0
- package/README.md +45 -0
- package/__tests__/testdata/b01_q7_get_prop_response.json +1 -0
- package/__tests__/testdata/b01_q7_map_fixture.json +16 -0
- package/config.schema.json +118 -0
- package/dist/crypto.js +65 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/live_message.js +25 -0
- package/dist/live_message.js.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/matter_vacuum_accessory.js +1877 -0
- package/dist/matter_vacuum_accessory.js.map +1 -0
- package/dist/platform.js +441 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +8 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/index.js +741 -0
- package/dist/ui/index.js.map +1 -0
- package/docs/matter-rvc-updating-homebridge-report.md +120 -0
- package/homebridge-roborock-vacuum.code-workspace +7 -0
- package/homebridge-ui/public/index.html +257 -0
- package/homebridge-ui/public/index.js +1276 -0
- package/homebridge-ui/public/styles.css +611 -0
- package/homebridge-ui/server.js +3 -0
- package/package.json +91 -0
- package/roborockLib/i18n/de/translations.json +188 -0
- package/roborockLib/i18n/en/translations.json +208 -0
- package/roborockLib/i18n/es/translations.json +188 -0
- package/roborockLib/i18n/fr/translations.json +188 -0
- package/roborockLib/i18n/it/translations.json +188 -0
- package/roborockLib/i18n/nl/translations.json +188 -0
- package/roborockLib/i18n/pl/translations.json +188 -0
- package/roborockLib/i18n/pt/translations.json +188 -0
- package/roborockLib/i18n/ru/translations.json +188 -0
- package/roborockLib/i18n/uk/translations.json +188 -0
- package/roborockLib/i18n/zh-cn/translations.json +188 -0
- package/roborockLib/lib/RRMapParser.js +459 -0
- package/roborockLib/lib/b01Q7Adapter.js +468 -0
- package/roborockLib/lib/deviceFeatures.js +1349 -0
- package/roborockLib/lib/localConnector.js +629 -0
- package/roborockLib/lib/map/map.html +137 -0
- package/roborockLib/lib/map/zones.js +851 -0
- package/roborockLib/lib/message.js +402 -0
- package/roborockLib/lib/messageQueueHandler.js +411 -0
- package/roborockLib/lib/roborockAuth.js +178 -0
- package/roborockLib/lib/roborockCrypto.js +55 -0
- package/roborockLib/lib/roborockPackageHelper.js +142 -0
- package/roborockLib/lib/roborock_mqtt_connector.js +567 -0
- package/roborockLib/lib/sniffing/mitmproxy_roborock.py +300 -0
- package/roborockLib/lib/vacuum.js +992 -0
- package/roborockLib/roborockAPI.js +3635 -0
- 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
|
+
////////////////////////////////////////////////////////////////////////////////////////////////////
|