@diveflo/matterbridge-mova 0.1.18

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.
@@ -0,0 +1,1096 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { inflateSync } from 'node:zlib';
3
+ import { MovaState, MovaStatus, MovaFanSpeed, MovaWaterFlow, MovaCleaningMode, MovaErrorCode, } from './types.js';
4
+ import { getMovaApiUrl, getMovaUserAgent, getMovaTenantId, MOVA_AUTH_ENDPOINT, MOVA_AUTH_HEADER, MOVA_CLIENT_ID, MOVA_DEVICE_LIST_ENDPOINT, MOVA_DOWNLOAD_ENDPOINT, MOVA_GET_DEVICE_DATA_ENDPOINT, MOVA_OSS_DOWNLOAD_ENDPOINT, MOVA_PASSWORD_SALT, getMovaSendCommandEndpoint, MIOT_PROPERTIES, MIOT_ACTIONS, MIOT_ACTION_PARAMS, MOVA_STATUS_VALUES, isSupportedModel, } from './constants.js';
5
+ const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
6
+ const RETRY_CONFIG = {
7
+ maxRetries: 3,
8
+ baseDelayMs: 1000,
9
+ maxDelayMs: 10000,
10
+ };
11
+ const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
12
+ export class MovaCloudProtocol {
13
+ log;
14
+ session = null;
15
+ mqttClients = new Map();
16
+ messageId = 0;
17
+ pendingRequests = new Map();
18
+ deviceStatusCallbacks = new Map();
19
+ deviceOwnerIds = new Map();
20
+ deviceModels = new Map();
21
+ deviceBindDomains = new Map();
22
+ loginCredentials = null;
23
+ isRefreshing = false;
24
+ constructor(log) {
25
+ this.log = log;
26
+ }
27
+ async ensureValidToken() {
28
+ if (!this.session) {
29
+ return false;
30
+ }
31
+ const now = Date.now();
32
+ const expiresAt = this.session.expiresAt;
33
+ if (now < expiresAt - TOKEN_REFRESH_BUFFER_MS) {
34
+ return true;
35
+ }
36
+ this.log.info('Token is expiring soon, refreshing...');
37
+ return this.refreshToken();
38
+ }
39
+ async refreshToken() {
40
+ if (this.isRefreshing) {
41
+ this.log.debug('Token refresh already in progress, waiting...');
42
+ await new Promise((resolve) => setTimeout(resolve, 1000));
43
+ return this.session !== null;
44
+ }
45
+ this.isRefreshing = true;
46
+ try {
47
+ if (this.loginCredentials) {
48
+ this.log.info('Re-authenticating with stored credentials...');
49
+ const result = await this.login(this.loginCredentials.username, this.loginCredentials.password, this.loginCredentials.country);
50
+ return result.success;
51
+ }
52
+ this.log.error('No credentials available for token refresh');
53
+ return false;
54
+ }
55
+ finally {
56
+ this.isRefreshing = false;
57
+ }
58
+ }
59
+ async login(username, password, country) {
60
+ this.log.info(`Logging in to Mova Cloud (${country})...`);
61
+ try {
62
+ const baseUrl = getMovaApiUrl(country);
63
+ const userAgent = getMovaUserAgent();
64
+ const tenantId = getMovaTenantId();
65
+ const passwordHash = createHash('md5')
66
+ .update(password + MOVA_PASSWORD_SALT)
67
+ .digest('hex');
68
+ const loginBody = `platform=IOS&scope=all&grant_type=password&username=${encodeURIComponent(username)}&password=${encodeURIComponent(passwordHash)}&type=account`;
69
+ this.log.debug(`Login URL: ${baseUrl}${MOVA_AUTH_ENDPOINT}`);
70
+ const headers = {
71
+ 'Accept': '*/*',
72
+ 'Content-Type': 'application/x-www-form-urlencoded',
73
+ 'Accept-Language': 'en-US;q=0.8',
74
+ 'Accept-Encoding': 'gzip, deflate',
75
+ 'User-Agent': userAgent,
76
+ 'Authorization': MOVA_AUTH_HEADER,
77
+ 'Tenant-Id': tenantId,
78
+ };
79
+ if (country === 'cn') {
80
+ headers['Dreame-Rlc'] = MOVA_CLIENT_ID;
81
+ }
82
+ const response = await fetch(`${baseUrl}${MOVA_AUTH_ENDPOINT}`, {
83
+ method: 'POST',
84
+ headers,
85
+ body: loginBody,
86
+ });
87
+ if (!response.ok) {
88
+ const text = await response.text();
89
+ this.log.error(`Login failed: ${response.status} ${text}`);
90
+ return { success: false, error: `Login failed: ${response.status} - ${text}` };
91
+ }
92
+ const data = (await response.json());
93
+ if (!data.access_token) {
94
+ return { success: false, error: data.msg || 'No access token received' };
95
+ }
96
+ const mqttKey = data.access_token;
97
+ this.session = {
98
+ token: data.access_token,
99
+ refreshToken: data.refresh_token || '',
100
+ expiresAt: Date.now() + (data.expires_in || 3600) * 1000,
101
+ userId: data.uid || data.tenant_id || '',
102
+ country,
103
+ mqttKey,
104
+ };
105
+ this.log.info(`Got MQTT credentials: uid=${this.session.userId}, key length=${mqttKey?.length || 0}`);
106
+ if (!this.isRefreshing) {
107
+ this.loginCredentials = { username, password, country };
108
+ }
109
+ this.log.info('Login successful');
110
+ return {
111
+ success: true,
112
+ token: this.session.token,
113
+ refreshToken: this.session.refreshToken,
114
+ expiresAt: this.session.expiresAt,
115
+ userId: this.session.userId,
116
+ };
117
+ }
118
+ catch (error) {
119
+ const message = error instanceof Error ? error.message : String(error);
120
+ this.log.error(`Login error: ${message}`);
121
+ return { success: false, error: message };
122
+ }
123
+ }
124
+ async apiCall(endpoint, method = 'GET', body) {
125
+ if (!this.session) {
126
+ this.log.error('No active session');
127
+ return null;
128
+ }
129
+ const tokenValid = await this.ensureValidToken();
130
+ if (!tokenValid) {
131
+ this.log.error('Failed to ensure valid token');
132
+ return null;
133
+ }
134
+ const baseUrl = getMovaApiUrl(this.session.country);
135
+ const userAgent = getMovaUserAgent();
136
+ const tenantId = getMovaTenantId();
137
+ const url = `${baseUrl}${endpoint}`;
138
+ const headers = {
139
+ 'Accept': '*/*',
140
+ 'Accept-Language': 'en-US;q=0.8',
141
+ 'Accept-Encoding': 'gzip, deflate',
142
+ 'Content-Type': 'application/json',
143
+ 'User-Agent': userAgent,
144
+ 'Authorization': MOVA_AUTH_HEADER,
145
+ 'Tenant-Id': tenantId,
146
+ 'Dreame-Auth': this.session.token,
147
+ };
148
+ if (this.session.country === 'cn') {
149
+ headers['Dreame-Rlc'] = MOVA_CLIENT_ID;
150
+ }
151
+ const options = {
152
+ method,
153
+ headers,
154
+ };
155
+ if (body) {
156
+ options.body = JSON.stringify(body);
157
+ }
158
+ let lastError = null;
159
+ for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
160
+ try {
161
+ if (attempt > 0) {
162
+ const delay = Math.min(RETRY_CONFIG.baseDelayMs * Math.pow(2, attempt - 1), RETRY_CONFIG.maxDelayMs);
163
+ const jitter = delay * 0.2 * Math.random();
164
+ this.log.debug(`Retry attempt ${attempt}/${RETRY_CONFIG.maxRetries} after ${Math.round(delay + jitter)}ms`);
165
+ await new Promise((resolve) => setTimeout(resolve, delay + jitter));
166
+ }
167
+ const response = await fetch(url, options);
168
+ if (!response.ok) {
169
+ if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < RETRY_CONFIG.maxRetries) {
170
+ lastError = new Error(`HTTP ${response.status}`);
171
+ this.log.warn(`API call got ${response.status}, will retry...`);
172
+ continue;
173
+ }
174
+ const text = await response.text();
175
+ this.log.error(`API call failed: ${response.status} - ${text}`);
176
+ return null;
177
+ }
178
+ return (await response.json());
179
+ }
180
+ catch (error) {
181
+ lastError = error instanceof Error ? error : new Error(String(error));
182
+ if (attempt < RETRY_CONFIG.maxRetries) {
183
+ this.log.warn(`API call error (attempt ${attempt + 1}): ${lastError.message}`);
184
+ continue;
185
+ }
186
+ }
187
+ }
188
+ this.log.error(`API call failed after ${RETRY_CONFIG.maxRetries + 1} attempts: ${lastError?.message}`);
189
+ return null;
190
+ }
191
+ async getDevices() {
192
+ let response = await this.apiCall(MOVA_DEVICE_LIST_ENDPOINT, 'POST', {
193
+ page: 1,
194
+ pageSize: 100,
195
+ });
196
+ if (!response?.data?.page?.records) {
197
+ response = await this.apiCall(MOVA_DEVICE_LIST_ENDPOINT, 'POST');
198
+ }
199
+ if (!response?.data?.page?.records) {
200
+ this.log.debug('No devices found in response');
201
+ this.log.debug(`Response: ${JSON.stringify(response)}`);
202
+ return [];
203
+ }
204
+ this.log.info(`API returned ${response.data.page.records.length} device(s)`);
205
+ const devices = response.data.page.records
206
+ .filter((d) => d.model?.startsWith('mova.vacuum') || isSupportedModel(d.model))
207
+ .map((d) => {
208
+ const deviceName = d.customName || d.deviceInfo?.displayName || `Mova ${d.model}`;
209
+ if (d.masterUid) {
210
+ this.deviceOwnerIds.set(d.did, d.masterUid);
211
+ }
212
+ this.deviceModels.set(d.did, d.model);
213
+ if (d.bindDomain) {
214
+ this.deviceBindDomains.set(d.did, d.bindDomain);
215
+ }
216
+ return {
217
+ did: d.did,
218
+ name: deviceName,
219
+ model: d.model,
220
+ mac: d.mac,
221
+ localIp: d.localIp || d.localip,
222
+ online: d.online,
223
+ ownerId: d.masterUid || '',
224
+ bindDomain: d.bindDomain,
225
+ property: d.property,
226
+ };
227
+ });
228
+ this.log.info(`Found ${devices.length} Mova vacuum(s)`);
229
+ return devices;
230
+ }
231
+ async getDeviceProperties(did) {
232
+ const props = [
233
+ { siid: MIOT_PROPERTIES.batteryLevel.siid, piid: MIOT_PROPERTIES.batteryLevel.piid },
234
+ { siid: MIOT_PROPERTIES.chargingState.siid, piid: MIOT_PROPERTIES.chargingState.piid },
235
+ { siid: MIOT_PROPERTIES.deviceFault.siid, piid: MIOT_PROPERTIES.deviceFault.piid },
236
+ { siid: MIOT_PROPERTIES.deviceStatus.siid, piid: MIOT_PROPERTIES.deviceStatus.piid },
237
+ { siid: MIOT_PROPERTIES.operatingMode.siid, piid: MIOT_PROPERTIES.operatingMode.piid },
238
+ { siid: MIOT_PROPERTIES.suctionLevel.siid, piid: MIOT_PROPERTIES.suctionLevel.piid },
239
+ { siid: MIOT_PROPERTIES.waterFlow.siid, piid: MIOT_PROPERTIES.waterFlow.piid },
240
+ { siid: MIOT_PROPERTIES.cleaningMode.siid, piid: MIOT_PROPERTIES.cleaningMode.piid },
241
+ { siid: MIOT_PROPERTIES.selfWashBaseStatus.siid, piid: MIOT_PROPERTIES.selfWashBaseStatus.piid },
242
+ { siid: MIOT_PROPERTIES.waterTankInstalled.siid, piid: MIOT_PROPERTIES.waterTankInstalled.piid },
243
+ { siid: MIOT_PROPERTIES.mopPadInstalled.siid, piid: MIOT_PROPERTIES.mopPadInstalled.piid },
244
+ { siid: MIOT_PROPERTIES.dustCollectionStatus.siid, piid: MIOT_PROPERTIES.dustCollectionStatus.piid },
245
+ { siid: MIOT_PROPERTIES.cleanWaterTankStatus.siid, piid: MIOT_PROPERTIES.cleanWaterTankStatus.piid },
246
+ { siid: MIOT_PROPERTIES.dirtyWaterTankStatus.siid, piid: MIOT_PROPERTIES.dirtyWaterTankStatus.piid },
247
+ ];
248
+ try {
249
+ const response = await this.sendCloudCommand(did, 'get_properties', props);
250
+ let results;
251
+ if (Array.isArray(response)) {
252
+ results = response;
253
+ }
254
+ else if (response && typeof response === 'object' && 'result' in response) {
255
+ const wrapped = response;
256
+ if (Array.isArray(wrapped.result)) {
257
+ results = wrapped.result;
258
+ }
259
+ else {
260
+ this.log.debug(`No valid result array in response: ${JSON.stringify(response)}`);
261
+ return null;
262
+ }
263
+ }
264
+ else {
265
+ return null;
266
+ }
267
+ const getValue = (siid, piid) => {
268
+ const prop = results.find((p) => p.siid === siid && p.piid === piid && p.code === 0);
269
+ return prop?.value;
270
+ };
271
+ const waterTankRaw = getValue(MIOT_PROPERTIES.waterTankInstalled.siid, MIOT_PROPERTIES.waterTankInstalled.piid);
272
+ const mopPadRaw = getValue(MIOT_PROPERTIES.mopPadInstalled.siid, MIOT_PROPERTIES.mopPadInstalled.piid);
273
+ const status = {
274
+ state: getValue(MIOT_PROPERTIES.operatingMode.siid, MIOT_PROPERTIES.operatingMode.piid) ?? MovaState.Unknown,
275
+ status: getValue(MIOT_PROPERTIES.deviceStatus.siid, MIOT_PROPERTIES.deviceStatus.piid) ?? MovaStatus.Unknown,
276
+ battery: getValue(MIOT_PROPERTIES.batteryLevel.siid, MIOT_PROPERTIES.batteryLevel.piid) ?? 0,
277
+ fanSpeed: getValue(MIOT_PROPERTIES.suctionLevel.siid, MIOT_PROPERTIES.suctionLevel.piid) ?? MovaFanSpeed.Standard,
278
+ waterFlow: getValue(MIOT_PROPERTIES.waterFlow.siid, MIOT_PROPERTIES.waterFlow.piid) ?? MovaWaterFlow.Medium,
279
+ cleaningMode: getValue(MIOT_PROPERTIES.cleaningMode.siid, MIOT_PROPERTIES.cleaningMode.piid),
280
+ errorCode: getValue(MIOT_PROPERTIES.deviceFault.siid, MIOT_PROPERTIES.deviceFault.piid) ?? MovaErrorCode.None,
281
+ waterTankInstalled: waterTankRaw !== undefined ? waterTankRaw === 1 : undefined,
282
+ mopPadInstalled: mopPadRaw !== undefined ? mopPadRaw === 1 : undefined,
283
+ dustCollectionStatus: getValue(MIOT_PROPERTIES.dustCollectionStatus.siid, MIOT_PROPERTIES.dustCollectionStatus.piid),
284
+ cleanWaterTankStatus: getValue(MIOT_PROPERTIES.cleanWaterTankStatus.siid, MIOT_PROPERTIES.cleanWaterTankStatus.piid),
285
+ dirtyWaterTankStatus: getValue(MIOT_PROPERTIES.dirtyWaterTankStatus.siid, MIOT_PROPERTIES.dirtyWaterTankStatus.piid),
286
+ };
287
+ const accessoryInfo = [];
288
+ if (status.waterTankInstalled !== undefined)
289
+ accessoryInfo.push(`waterTank=${status.waterTankInstalled}`);
290
+ if (status.mopPadInstalled !== undefined)
291
+ accessoryInfo.push(`mopPad=${status.mopPadInstalled}`);
292
+ const accessoryStr = accessoryInfo.length > 0 ? `, ${accessoryInfo.join(', ')}` : '';
293
+ this.log.info(`Device status: state=${status.state}, status=${status.status}, battery=${status.battery}%${accessoryStr}`);
294
+ this.cachedStatus.set(did, status);
295
+ return status;
296
+ }
297
+ catch (error) {
298
+ this.log.error(`Failed to get device properties: ${error}`);
299
+ return null;
300
+ }
301
+ }
302
+ async getRoomInfo(did) {
303
+ const cachedRooms = this.cachedRooms.get(did);
304
+ if (cachedRooms && cachedRooms.length > 0) {
305
+ this.log.info(`Using ${cachedRooms.length} cached rooms from MQTT for ${did}`);
306
+ return cachedRooms;
307
+ }
308
+ this.log.debug('No cached room data available - rooms will be received via MQTT when device is active');
309
+ return [];
310
+ }
311
+ decodeMapPayload(mapData) {
312
+ try {
313
+ if (typeof mapData === 'string') {
314
+ try {
315
+ return JSON.parse(mapData);
316
+ }
317
+ catch {
318
+ try {
319
+ const decoded = Buffer.from(mapData, 'base64');
320
+ if (decoded[0] === 0x78) {
321
+ const decompressed = inflateSync(decoded);
322
+ if (decompressed.length > 1000) {
323
+ const searchStart = Math.max(0, decompressed.length - 50000);
324
+ const endSection = decompressed.subarray(searchStart).toString('latin1');
325
+ const rismIndex = endSection.indexOf('"rism"');
326
+ if (rismIndex !== -1) {
327
+ for (let searchBack = rismIndex; searchBack >= 0; searchBack--) {
328
+ if (endSection[searchBack] !== '{')
329
+ continue;
330
+ let braceCount = 0;
331
+ let braceEnd = -1;
332
+ for (let i = searchBack; i < endSection.length; i++) {
333
+ if (endSection[i] === '{')
334
+ braceCount++;
335
+ if (endSection[i] === '}')
336
+ braceCount--;
337
+ if (braceCount === 0) {
338
+ braceEnd = i;
339
+ break;
340
+ }
341
+ }
342
+ if (braceEnd > searchBack) {
343
+ const jsonStr = endSection.substring(searchBack, braceEnd + 1);
344
+ if (jsonStr.includes('"rism"') && jsonStr.length < 50000) {
345
+ try {
346
+ return JSON.parse(jsonStr);
347
+ }
348
+ catch {
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ }
356
+ else {
357
+ return JSON.parse(decoded.toString('utf-8'));
358
+ }
359
+ }
360
+ catch {
361
+ return null;
362
+ }
363
+ }
364
+ }
365
+ else if (typeof mapData === 'object' && mapData !== null) {
366
+ return mapData;
367
+ }
368
+ return null;
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ }
374
+ decodeRismPayload(rism) {
375
+ if (!rism || typeof rism !== 'string' || rism.length === 0) {
376
+ return null;
377
+ }
378
+ try {
379
+ const standardBase64 = rism.replace(/-/g, '+').replace(/_/g, '/');
380
+ const rismDecoded = Buffer.from(standardBase64, 'base64');
381
+ if (rismDecoded[0] !== 0x78) {
382
+ return JSON.parse(rismDecoded.toString('utf-8'));
383
+ }
384
+ const rismDecompressed = inflateSync(rismDecoded);
385
+ const rismStr = rismDecompressed.toString('utf-8');
386
+ try {
387
+ return JSON.parse(rismStr);
388
+ }
389
+ catch {
390
+ const segInfIndex = rismStr.indexOf('"seg_inf"');
391
+ if (segInfIndex === -1) {
392
+ return null;
393
+ }
394
+ const segInfStart = rismStr.indexOf('{', segInfIndex);
395
+ if (segInfStart === -1) {
396
+ return null;
397
+ }
398
+ let braceCount = 0;
399
+ let segInfEnd = -1;
400
+ for (let i = segInfStart; i < rismStr.length; i++) {
401
+ if (rismStr[i] === '{')
402
+ braceCount++;
403
+ if (rismStr[i] === '}')
404
+ braceCount--;
405
+ if (braceCount === 0) {
406
+ segInfEnd = i;
407
+ break;
408
+ }
409
+ }
410
+ if (segInfEnd <= segInfStart) {
411
+ return null;
412
+ }
413
+ return { seg_inf: JSON.parse(rismStr.substring(segInfStart, segInfEnd + 1)) };
414
+ }
415
+ }
416
+ catch {
417
+ return null;
418
+ }
419
+ }
420
+ parseRoomsFromMapData(mapData) {
421
+ try {
422
+ const parsed = this.decodeMapPayload(mapData);
423
+ if (!parsed) {
424
+ return [];
425
+ }
426
+ const rism = this.decodeRismPayload(parsed.rism);
427
+ if (parsed.seg_inf) {
428
+ return this.parseSegInf(parsed.seg_inf);
429
+ }
430
+ if (rism?.seg_inf) {
431
+ return this.parseSegInf(rism.seg_inf);
432
+ }
433
+ return [];
434
+ }
435
+ catch (error) {
436
+ this.log.error(`Failed to parse map data: ${error}`);
437
+ return [];
438
+ }
439
+ }
440
+ static ROOM_TYPE_NAMES = {
441
+ 0: 'Room',
442
+ 1: 'Living Room',
443
+ 2: 'Primary Bedroom',
444
+ 3: 'Study',
445
+ 4: 'Kitchen',
446
+ 5: 'Dining Hall',
447
+ 6: 'Bathroom',
448
+ 7: 'Balcony',
449
+ 8: 'Corridor',
450
+ 9: 'Utility Room',
451
+ 10: 'Closet',
452
+ 11: 'Meeting Room',
453
+ 12: 'Office',
454
+ 13: 'Fitness Area',
455
+ 14: 'Recreation Area',
456
+ 15: 'Secondary Bedroom',
457
+ };
458
+ parseSegInf(segInf) {
459
+ const rooms = [];
460
+ const keys = Object.keys(segInf);
461
+ const typeCounters = {};
462
+ for (const segIdStr of keys) {
463
+ const segId = parseInt(segIdStr, 10);
464
+ if (isNaN(segId))
465
+ continue;
466
+ const segData = segInf[segIdStr];
467
+ const roomType = typeof segData?.type === 'number' ? segData.type : 0;
468
+ let roomName = null;
469
+ if (segData?.name && typeof segData.name === 'string' && segData.name.length > 0) {
470
+ try {
471
+ const decoded = Buffer.from(segData.name, 'base64').toString('utf-8');
472
+ if (decoded && decoded.trim().length > 0) {
473
+ roomName = decoded;
474
+ }
475
+ }
476
+ catch {
477
+ if (segData.name.trim().length > 0) {
478
+ roomName = segData.name;
479
+ }
480
+ }
481
+ }
482
+ if (!roomName) {
483
+ const typeName = MovaCloudProtocol.ROOM_TYPE_NAMES[roomType] || 'Room';
484
+ typeCounters[roomType] = (typeCounters[roomType] || 0) + 1;
485
+ if (typeCounters[roomType] > 1) {
486
+ roomName = `${typeName} ${typeCounters[roomType]}`;
487
+ }
488
+ else {
489
+ roomName = typeName;
490
+ }
491
+ }
492
+ rooms.push({
493
+ id: segId,
494
+ name: roomName,
495
+ floorId: roomType,
496
+ });
497
+ }
498
+ if (rooms.length > 0) {
499
+ this.log.info(`Extracted ${rooms.length} rooms: ${rooms.map((r) => r.name).join(', ')}`);
500
+ }
501
+ return rooms;
502
+ }
503
+ async tryFetchMapOnStartup(did) {
504
+ const ownerId = this.deviceOwnerIds.get(did);
505
+ if (!ownerId) {
506
+ this.log.debug('No owner ID stored, cannot try proactive map fetch');
507
+ return false;
508
+ }
509
+ const mapPaths = [`ali_dreame/${ownerId}/${did}/1`, `ali_dreame/${ownerId}/${did}/0`, `ali_dreame/${ownerId}/${did}/2`];
510
+ for (const path of mapPaths) {
511
+ this.log.info(`Proactively trying to fetch map from: ${path}`);
512
+ await this.fetchMapFromCloudStorage(did, path);
513
+ const rooms = this.cachedRooms.get(did);
514
+ if (rooms && rooms.length > 0) {
515
+ this.log.info(`Successfully fetched ${rooms.length} rooms from ${path}`);
516
+ return true;
517
+ }
518
+ }
519
+ this.log.debug('Proactive map fetch did not find any rooms');
520
+ return false;
521
+ }
522
+ async fetchMapFromCloudStorage(did, objectPath) {
523
+ try {
524
+ this.log.info(`Fetching map data from cloud storage: ${objectPath}`);
525
+ const mapKeys = [`${objectPath}/map`, `${objectPath}/seg_inf`, `${objectPath}`];
526
+ for (const key of mapKeys) {
527
+ const response = await this.apiCall(MOVA_GET_DEVICE_DATA_ENDPOINT, 'POST', {
528
+ did,
529
+ model: [key],
530
+ });
531
+ if (response?.code === 0 && response.data) {
532
+ for (const [, value] of Object.entries(response.data)) {
533
+ if (value && typeof value === 'string' && value.length > 50) {
534
+ if (value.includes('seg_inf') || value.includes('"name"')) {
535
+ const rooms = this.parseRoomsFromMapData(value);
536
+ if (rooms.length > 0) {
537
+ this.log.info(`Found ${rooms.length} rooms from cloud storage: ${rooms.map((r) => r.name).join(', ')}`);
538
+ this.cachedRooms.set(did, rooms);
539
+ const roomCallback = this.roomUpdateCallbacks.get(did);
540
+ if (roomCallback) {
541
+ roomCallback(rooms);
542
+ }
543
+ return;
544
+ }
545
+ }
546
+ }
547
+ }
548
+ }
549
+ }
550
+ const deviceModel = this.deviceModels.get(did);
551
+ if (!deviceModel) {
552
+ this.log.debug('No device model stored, cannot fetch from cloud storage');
553
+ return;
554
+ }
555
+ const tryDownloadMap = async (url, source) => {
556
+ try {
557
+ const mapResponse = await fetch(url);
558
+ if (mapResponse.ok) {
559
+ const mapText = await mapResponse.text();
560
+ let rooms = this.parseRoomsFromMapData(mapText);
561
+ if (rooms.length > 0) {
562
+ this.log.info(`Found ${rooms.length} rooms from ${source}: ${rooms.map((r) => r.name).join(', ')}`);
563
+ this.cachedRooms.set(did, rooms);
564
+ const roomCallback = this.roomUpdateCallbacks.get(did);
565
+ if (roomCallback) {
566
+ roomCallback(rooms);
567
+ }
568
+ return true;
569
+ }
570
+ if (mapText.charCodeAt(0) < 32 || mapText.charCodeAt(0) > 126) {
571
+ const mapBuffer = await (await fetch(url)).arrayBuffer();
572
+ rooms = this.parseRoomsFromMapData(Buffer.from(mapBuffer).toString('base64'));
573
+ if (rooms.length > 0) {
574
+ this.log.info(`Found ${rooms.length} rooms from ${source}: ${rooms.map((r) => r.name).join(', ')}`);
575
+ this.cachedRooms.set(did, rooms);
576
+ const roomCallback = this.roomUpdateCallbacks.get(did);
577
+ if (roomCallback) {
578
+ roomCallback(rooms);
579
+ }
580
+ return true;
581
+ }
582
+ }
583
+ }
584
+ }
585
+ catch {
586
+ }
587
+ return false;
588
+ };
589
+ const ossResponse = await this.apiCall(MOVA_OSS_DOWNLOAD_ENDPOINT, 'POST', {
590
+ did,
591
+ filename: objectPath,
592
+ model: deviceModel,
593
+ });
594
+ if (ossResponse?.code === 0 && ossResponse.data) {
595
+ if (typeof ossResponse.data === 'string' && ossResponse.data.startsWith('http')) {
596
+ if (await tryDownloadMap(ossResponse.data, 'OSS'))
597
+ return;
598
+ }
599
+ else if (typeof ossResponse.data === 'object') {
600
+ for (const [key, url] of Object.entries(ossResponse.data)) {
601
+ if (url && typeof url === 'string' && url.startsWith('http')) {
602
+ if (await tryDownloadMap(url, `OSS[${key}]`))
603
+ return;
604
+ }
605
+ }
606
+ }
607
+ }
608
+ const fileResponse = await this.apiCall(MOVA_DOWNLOAD_ENDPOINT, 'POST', {
609
+ did,
610
+ filename: objectPath,
611
+ model: deviceModel,
612
+ });
613
+ if (fileResponse?.code === 0 && fileResponse.data) {
614
+ if (typeof fileResponse.data === 'string' && fileResponse.data.startsWith('http')) {
615
+ if (await tryDownloadMap(fileResponse.data, 'Download'))
616
+ return;
617
+ }
618
+ else if (typeof fileResponse.data === 'object') {
619
+ for (const [key, url] of Object.entries(fileResponse.data)) {
620
+ if (url && typeof url === 'string' && url.startsWith('http')) {
621
+ if (await tryDownloadMap(url, `Download[${key}]`))
622
+ return;
623
+ }
624
+ }
625
+ }
626
+ }
627
+ this.log.debug('No room data found in cloud storage');
628
+ }
629
+ catch (error) {
630
+ this.log.debug(`Failed to fetch map from cloud storage: ${error}`);
631
+ }
632
+ }
633
+ async connectMqtt(device) {
634
+ if (!this.session || !device.bindDomain) {
635
+ this.log.warn(`Cannot connect MQTT: no session=${!!this.session} or bindDomain=${device.bindDomain}`);
636
+ return false;
637
+ }
638
+ const { userId: uid, mqttKey: accessToken, token } = this.session;
639
+ if (!uid || !accessToken) {
640
+ this.log.warn(`Cannot connect MQTT: missing uid=${!!uid} or access_token=${!!accessToken}`);
641
+ return false;
642
+ }
643
+ const [host, portStr] = device.bindDomain.split(':');
644
+ const port = parseInt(portStr, 10) || 8883;
645
+ const randomId = Math.random().toString(36).substring(2, 15);
646
+ const clientId = `p_${uid}_${randomId}_${host}`;
647
+ this.log.info(`Connecting MQTT to ${host}:${port}`);
648
+ try {
649
+ const mqtt = await import('mqtt');
650
+ const client = mqtt.connect({
651
+ host,
652
+ port,
653
+ protocol: 'mqtts',
654
+ username: uid,
655
+ password: accessToken,
656
+ clientId,
657
+ rejectUnauthorized: false,
658
+ connectTimeout: 15000,
659
+ keepalive: 60,
660
+ clean: true,
661
+ reconnectPeriod: 5000,
662
+ });
663
+ return new Promise((resolve) => {
664
+ const timeout = setTimeout(() => {
665
+ this.log.warn('MQTT connection timeout after 20s');
666
+ client.end(true);
667
+ resolve(false);
668
+ }, 20000);
669
+ client.on('connect', () => {
670
+ clearTimeout(timeout);
671
+ this.log.info(`MQTT connected for ${device.name}`);
672
+ this.mqttClients.set(device.did, client);
673
+ const country = this.session?.country || 'eu';
674
+ const topics = [`/status/${device.did}/${uid}/${device.model}/${country}/`, `/status/${device.did}/#`, `+/${device.did}/#`];
675
+ for (const topic of topics) {
676
+ client.subscribe(topic, { qos: 0 });
677
+ }
678
+ this.requestMapDataViaMqtt(device.did, client);
679
+ resolve(true);
680
+ });
681
+ client.on('error', (err) => {
682
+ clearTimeout(timeout);
683
+ this.log.warn(`MQTT error: ${err.message}`);
684
+ if (err.message.includes('Not authorized') || err.message.includes('Connection refused')) {
685
+ this.log.debug(`Auth may have failed. uid=${uid}, token preview=${token.substring(0, 20)}...`);
686
+ }
687
+ resolve(false);
688
+ });
689
+ client.on('offline', () => {
690
+ this.log.debug('MQTT client went offline');
691
+ });
692
+ client.on('reconnect', () => {
693
+ this.log.debug('MQTT client reconnecting...');
694
+ });
695
+ client.on('message', (topic, payload) => {
696
+ this.handleMqttMessage(device.did, topic, payload.toString());
697
+ });
698
+ client.on('close', () => {
699
+ this.log.debug(`MQTT connection closed for ${device.name}`);
700
+ this.mqttClients.delete(device.did);
701
+ });
702
+ });
703
+ }
704
+ catch (error) {
705
+ this.log.error(`MQTT connection failed: ${error}`);
706
+ return false;
707
+ }
708
+ }
709
+ requestMapDataViaMqtt(did, client) {
710
+ const id = ++this.messageId;
711
+ const uid = this.session?.userId || '';
712
+ const message = {
713
+ id,
714
+ method: 'get_properties',
715
+ params: [
716
+ { siid: 6, piid: 1 },
717
+ { siid: 6, piid: 2 },
718
+ { siid: 6, piid: 8 },
719
+ { siid: 99, piid: 98 },
720
+ ],
721
+ };
722
+ const commandTopic = `device/${did}/command`;
723
+ const altCommandTopic = `/command/${did}/${uid}/`;
724
+ client.publish(commandTopic, JSON.stringify(message), { qos: 0 });
725
+ client.publish(altCommandTopic, JSON.stringify(message), { qos: 0 });
726
+ const actionMessage = {
727
+ id: ++this.messageId,
728
+ method: 'action',
729
+ params: {
730
+ siid: 6,
731
+ aiid: 1,
732
+ in: [],
733
+ },
734
+ };
735
+ setTimeout(() => {
736
+ client.publish(commandTopic, JSON.stringify(actionMessage), { qos: 0 });
737
+ }, 2000);
738
+ }
739
+ handleMqttMessage(did, _topic, payload) {
740
+ try {
741
+ const data = JSON.parse(payload);
742
+ if (data.id !== undefined && typeof data.id === 'number') {
743
+ const pending = this.pendingRequests.get(data.id);
744
+ if (pending) {
745
+ this.pendingRequests.delete(data.id);
746
+ if (data.error) {
747
+ pending.reject(new Error(String(data.error)));
748
+ }
749
+ else {
750
+ pending.resolve(data.result);
751
+ }
752
+ }
753
+ }
754
+ const innerData = data.data || data;
755
+ const params = innerData.params;
756
+ if (params && Array.isArray(params)) {
757
+ const props = params;
758
+ const status = this.parseStatusFromMqtt(did, params);
759
+ if (status) {
760
+ const callback = this.deviceStatusCallbacks.get(did);
761
+ if (callback) {
762
+ callback(status);
763
+ }
764
+ }
765
+ const mapDataProps = props.filter((p) => (p.siid === 6 && (p.piid === 1 || p.piid === 2 || p.piid === 8)) || (p.siid === 99 && p.piid === 98));
766
+ for (const prop of mapDataProps) {
767
+ if (prop.value) {
768
+ const rooms = this.parseRoomsFromMapData(prop.value);
769
+ if (rooms.length > 0) {
770
+ this.log.info(`Parsed ${rooms.length} rooms from MQTT: ${rooms.map((r) => r.name).join(', ')}`);
771
+ this.cachedRooms.set(did, rooms);
772
+ const roomCallback = this.roomUpdateCallbacks.get(did);
773
+ if (roomCallback) {
774
+ roomCallback(rooms);
775
+ }
776
+ }
777
+ }
778
+ }
779
+ const mapPathProp = props.find((p) => p.siid === 6 && p.piid === 3);
780
+ if (mapPathProp?.value && typeof mapPathProp.value === 'string') {
781
+ this.log.info(`Received map storage path: ${mapPathProp.value}`);
782
+ this.fetchMapFromCloudStorage(did, mapPathProp.value).catch((err) => {
783
+ this.log.debug(`Failed to fetch map from cloud storage: ${err}`);
784
+ });
785
+ }
786
+ }
787
+ if (data.result && Array.isArray(data.result)) {
788
+ this.log.debug(`MQTT result array with ${data.result.length} items`);
789
+ const results = data.result;
790
+ for (const result of results) {
791
+ if (result.siid === 6 && result.value && result.code === 0) {
792
+ this.log.info(`Got map property from MQTT result: siid=6 piid=${result.piid}`);
793
+ const rooms = this.parseRoomsFromMapData(result.value);
794
+ if (rooms.length > 0) {
795
+ this.log.info(`Parsed ${rooms.length} rooms from MQTT result: ${rooms.map((r) => r.name).join(', ')}`);
796
+ this.cachedRooms.set(did, rooms);
797
+ const roomCallback = this.roomUpdateCallbacks.get(did);
798
+ if (roomCallback) {
799
+ roomCallback(rooms);
800
+ }
801
+ }
802
+ }
803
+ }
804
+ }
805
+ }
806
+ catch (e) {
807
+ this.log.debug(`Failed to parse MQTT message: ${e}`);
808
+ this.log.debug(`Raw payload: ${payload.substring(0, 200)}`);
809
+ }
810
+ }
811
+ roomUpdateCallbacks = new Map();
812
+ onRoomUpdate(did, callback) {
813
+ this.roomUpdateCallbacks.set(did, callback);
814
+ }
815
+ cachedRooms = new Map();
816
+ cachedStatus = new Map();
817
+ getCachedRooms(did) {
818
+ return this.cachedRooms.get(did) || [];
819
+ }
820
+ parseStatusFromMqtt(did, params) {
821
+ try {
822
+ const props = params;
823
+ const primaryStateProps = [
824
+ { siid: MIOT_PROPERTIES.deviceStatus.siid, piid: MIOT_PROPERTIES.deviceStatus.piid },
825
+ { siid: MIOT_PROPERTIES.operatingMode.siid, piid: MIOT_PROPERTIES.operatingMode.piid },
826
+ ];
827
+ const secondaryStateProps = [
828
+ { siid: MIOT_PROPERTIES.suctionLevel.siid, piid: MIOT_PROPERTIES.suctionLevel.piid },
829
+ ];
830
+ const hasPrimaryStateProps = props.some((p) => primaryStateProps.some((r) => r.siid === p.siid && r.piid === p.piid));
831
+ const hasSecondaryStateProps = props.some((p) => secondaryStateProps.some((r) => r.siid === p.siid && r.piid === p.piid));
832
+ if (hasSecondaryStateProps && !hasPrimaryStateProps) {
833
+ this.log.info(`Detected suctionLevel change without state update - triggering status poll for ${did}`);
834
+ this.getDeviceProperties(did)
835
+ .then((freshStatus) => {
836
+ if (freshStatus) {
837
+ this.log.info(`Poll returned fresh status: state=${freshStatus.state}, status=${freshStatus.status}`);
838
+ const callback = this.deviceStatusCallbacks.get(did);
839
+ callback?.(freshStatus);
840
+ }
841
+ return undefined;
842
+ })
843
+ .catch((e) => this.log.debug(`Status poll failed: ${e}`));
844
+ return null;
845
+ }
846
+ if (!hasPrimaryStateProps && !hasSecondaryStateProps) {
847
+ const batteryProp = props.find((p) => p.siid === MIOT_PROPERTIES.batteryLevel.siid && p.piid === MIOT_PROPERTIES.batteryLevel.piid);
848
+ if (batteryProp && typeof batteryProp.value === 'number') {
849
+ const cached = this.cachedStatus.get(did);
850
+ if (cached) {
851
+ cached.battery = batteryProp.value;
852
+ this.cachedStatus.set(did, cached);
853
+ this.log.debug(`Updated cached battery to ${batteryProp.value}% (no state change)`);
854
+ }
855
+ }
856
+ return null;
857
+ }
858
+ const getValue = (siid, piid) => {
859
+ const prop = props.find((p) => p.siid === siid && p.piid === piid);
860
+ return prop?.value;
861
+ };
862
+ const cached = this.cachedStatus.get(did) || {
863
+ state: MovaState.Unknown,
864
+ status: MovaStatus.Unknown,
865
+ battery: 0,
866
+ fanSpeed: MovaFanSpeed.Standard,
867
+ waterFlow: MovaWaterFlow.Medium,
868
+ cleaningMode: undefined,
869
+ errorCode: MovaErrorCode.None,
870
+ };
871
+ const newStatus = {
872
+ state: getValue(MIOT_PROPERTIES.operatingMode.siid, MIOT_PROPERTIES.operatingMode.piid) ?? cached.state,
873
+ status: getValue(MIOT_PROPERTIES.deviceStatus.siid, MIOT_PROPERTIES.deviceStatus.piid) ?? cached.status,
874
+ battery: getValue(MIOT_PROPERTIES.batteryLevel.siid, MIOT_PROPERTIES.batteryLevel.piid) ?? cached.battery,
875
+ fanSpeed: getValue(MIOT_PROPERTIES.suctionLevel.siid, MIOT_PROPERTIES.suctionLevel.piid) ?? cached.fanSpeed,
876
+ waterFlow: getValue(MIOT_PROPERTIES.waterFlow.siid, MIOT_PROPERTIES.waterFlow.piid) ?? cached.waterFlow,
877
+ cleaningMode: getValue(MIOT_PROPERTIES.cleaningMode.siid, MIOT_PROPERTIES.cleaningMode.piid) ?? cached.cleaningMode,
878
+ errorCode: getValue(MIOT_PROPERTIES.deviceFault.siid, MIOT_PROPERTIES.deviceFault.piid) ?? cached.errorCode,
879
+ };
880
+ this.cachedStatus.set(did, newStatus);
881
+ this.log.debug(`Device ${did} raw values: state=${newStatus.state}, status=${newStatus.status}`);
882
+ return newStatus;
883
+ }
884
+ catch {
885
+ return null;
886
+ }
887
+ }
888
+ async sendCommand(did, method, params = []) {
889
+ return this.sendCloudCommand(did, method, params);
890
+ }
891
+ async sendCloudCommand(did, method, params) {
892
+ const id = ++this.messageId;
893
+ let formattedParams;
894
+ if (method === 'action' && Array.isArray(params) && params.length >= 2) {
895
+ formattedParams = {
896
+ did,
897
+ siid: params[0],
898
+ aiid: params[1],
899
+ in: params[2] || [],
900
+ };
901
+ }
902
+ else if (method === 'set_properties' && Array.isArray(params)) {
903
+ formattedParams = params.map((p) => {
904
+ const prop = p;
905
+ return { did, siid: prop.siid, piid: prop.piid, value: prop.value };
906
+ });
907
+ }
908
+ else {
909
+ formattedParams = params;
910
+ }
911
+ const requestBody = {
912
+ did,
913
+ id,
914
+ data: {
915
+ did,
916
+ id,
917
+ method,
918
+ params: formattedParams,
919
+ },
920
+ };
921
+ this.log.info(`Sending cloud command: ${method}`);
922
+ this.log.debug(`Request body: ${JSON.stringify(requestBody)}`);
923
+ const response = await this.apiCall(getMovaSendCommandEndpoint(this.deviceBindDomains.get(did)), 'POST', requestBody);
924
+ this.log.info(`Cloud command ${method} response: code=${response?.code}, msg=${response?.msg}, data=${JSON.stringify(response?.data)}`);
925
+ if (response?.code !== 0) {
926
+ this.log.error(`Cloud command ${method} failed: code=${response?.code}, msg=${response?.msg}`);
927
+ }
928
+ return response?.data;
929
+ }
930
+ onDeviceStatus(did, callback) {
931
+ this.deviceStatusCallbacks.set(did, callback);
932
+ }
933
+ async confirmCommandSuccess(did, expectedStates, expectedStatuses, maxAttempts = 5, pollIntervalMs = 2000) {
934
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
935
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
936
+ const status = await this.getDeviceProperties(did);
937
+ if (!status) {
938
+ this.log.debug(`Confirmation poll ${attempt}/${maxAttempts}: failed to get status`);
939
+ continue;
940
+ }
941
+ const stateMatch = expectedStates.includes(status.state);
942
+ const statusMatch = !expectedStatuses || expectedStatuses.includes(status.status);
943
+ if (stateMatch && statusMatch) {
944
+ this.log.info(`Command confirmed: state=${status.state}, status=${status.status}`);
945
+ return true;
946
+ }
947
+ this.log.debug(`Confirmation poll ${attempt}/${maxAttempts}: state=${status.state}, status=${status.status} (expected states: ${expectedStates.join(',')})`);
948
+ }
949
+ this.log.warn(`Command confirmation failed after ${maxAttempts} attempts`);
950
+ return false;
951
+ }
952
+ async startCleaning(did, cleaningMode, fanSpeed, confirm = false) {
953
+ try {
954
+ if (cleaningMode !== undefined) {
955
+ await this.setCleaningMode(did, cleaningMode);
956
+ }
957
+ if (fanSpeed !== undefined) {
958
+ await this.setSuctionLevel(did, fanSpeed);
959
+ }
960
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.startClean.siid, MIOT_ACTIONS.startClean.aiid, []]);
961
+ if (confirm) {
962
+ return this.confirmCommandSuccess(did, [MovaState.Cleaning, MovaState.Mopping, MovaState.ZonedCleaning, MovaState.ManualCleaning]);
963
+ }
964
+ return true;
965
+ }
966
+ catch (error) {
967
+ this.log.error(`Failed to start cleaning: ${error}`);
968
+ return false;
969
+ }
970
+ }
971
+ async stopCleaning(did, confirm = false) {
972
+ try {
973
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.stopClean.siid, MIOT_ACTIONS.stopClean.aiid, []]);
974
+ if (confirm) {
975
+ return this.confirmCommandSuccess(did, [MovaState.Idle, MovaState.Paused, MovaState.Dormant]);
976
+ }
977
+ return true;
978
+ }
979
+ catch (error) {
980
+ this.log.error(`Failed to stop cleaning: ${error}`);
981
+ return false;
982
+ }
983
+ }
984
+ async pauseCleaning(did, confirm = false) {
985
+ try {
986
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.pauseClean.siid, MIOT_ACTIONS.pauseClean.aiid, []]);
987
+ if (confirm) {
988
+ return this.confirmCommandSuccess(did, [MovaState.Paused], [MovaStatus.Paused]);
989
+ }
990
+ return true;
991
+ }
992
+ catch (error) {
993
+ this.log.error(`Failed to pause cleaning: ${error}`);
994
+ return false;
995
+ }
996
+ }
997
+ async resumeCleaning(did, confirm = false) {
998
+ try {
999
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.startClean.siid, MIOT_ACTIONS.startClean.aiid, []]);
1000
+ if (confirm) {
1001
+ return this.confirmCommandSuccess(did, [MovaState.Cleaning, MovaState.Mopping, MovaState.ZonedCleaning, MovaState.ManualCleaning]);
1002
+ }
1003
+ return true;
1004
+ }
1005
+ catch (error) {
1006
+ this.log.error(`Failed to resume cleaning: ${error}`);
1007
+ return false;
1008
+ }
1009
+ }
1010
+ async goHome(did, confirm = false) {
1011
+ try {
1012
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.charge.siid, MIOT_ACTIONS.charge.aiid, []]);
1013
+ if (confirm) {
1014
+ return this.confirmCommandSuccess(did, [MovaState.Returning, MovaState.GoCharging, MovaState.Charging, MovaState.Idle], [MovaStatus.BackHome, MovaStatus.Charging, MovaStatus.Standby]);
1015
+ }
1016
+ return true;
1017
+ }
1018
+ catch (error) {
1019
+ this.log.error(`Failed to send vacuum home: ${error}`);
1020
+ return false;
1021
+ }
1022
+ }
1023
+ async locate(did) {
1024
+ try {
1025
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.locate.siid, MIOT_ACTIONS.locate.aiid, []]);
1026
+ return true;
1027
+ }
1028
+ catch (error) {
1029
+ this.log.error(`Failed to locate vacuum: ${error}`);
1030
+ return false;
1031
+ }
1032
+ }
1033
+ async cleanRooms(did, roomIds, repeat = 1, cleaningMode = MovaCleaningMode.SweepingAndMopping, fanSpeed) {
1034
+ try {
1035
+ await this.setCleaningMode(did, cleaningMode);
1036
+ if (fanSpeed !== undefined) {
1037
+ await this.setSuctionLevel(did, fanSpeed);
1038
+ }
1039
+ const suctionLevel = fanSpeed ?? MovaFanSpeed.Standard;
1040
+ const waterLevel = this.roomWaterLevelForCleaningMode(cleaningMode);
1041
+ const segmentData = JSON.stringify({
1042
+ selects: roomIds.map((id, index) => [id, repeat, suctionLevel, waterLevel, index + 1]),
1043
+ });
1044
+ this.log.info(`Starting room clean for ${roomIds.join(', ')} (mode=${cleaningMode}, fan=${suctionLevel})`);
1045
+ this.log.debug(`Segment data: ${segmentData}`);
1046
+ const payload = [
1047
+ { piid: MIOT_ACTION_PARAMS.status, value: MOVA_STATUS_VALUES.segmentCleaning },
1048
+ { piid: MIOT_ACTION_PARAMS.cleaningProperties, value: segmentData },
1049
+ ];
1050
+ await this.sendCommand(did, 'action', [MIOT_ACTIONS.startCustom.siid, MIOT_ACTIONS.startCustom.aiid, payload]);
1051
+ return true;
1052
+ }
1053
+ catch (error) {
1054
+ this.log.error(`Failed to clean rooms: ${error}`);
1055
+ return false;
1056
+ }
1057
+ }
1058
+ async setCleaningMode(did, cleaningMode) {
1059
+ this.log.info(`Setting cleaning mode to ${cleaningMode}`);
1060
+ await this.sendCommand(did, 'set_properties', [
1061
+ {
1062
+ siid: MIOT_PROPERTIES.cleaningMode.siid,
1063
+ piid: MIOT_PROPERTIES.cleaningMode.piid,
1064
+ value: cleaningMode,
1065
+ },
1066
+ ]);
1067
+ }
1068
+ roomWaterLevelForCleaningMode(cleaningMode) {
1069
+ return cleaningMode === MovaCleaningMode.SweepingAndMopping ? 0 : 2;
1070
+ }
1071
+ async setSuctionLevel(did, fanSpeed) {
1072
+ this.log.info(`Setting suction level to ${fanSpeed} (0=quiet, 1=standard, 2=strong, 3=turbo)`);
1073
+ await this.sendCommand(did, 'set_properties', [
1074
+ {
1075
+ siid: MIOT_PROPERTIES.suctionLevel.siid,
1076
+ piid: MIOT_PROPERTIES.suctionLevel.piid,
1077
+ value: fanSpeed,
1078
+ },
1079
+ ]);
1080
+ }
1081
+ async disconnect() {
1082
+ for (const [did, client] of this.mqttClients) {
1083
+ this.log.info(`Disconnecting from ${did}`);
1084
+ client.end();
1085
+ }
1086
+ this.mqttClients.clear();
1087
+ this.pendingRequests.clear();
1088
+ this.deviceStatusCallbacks.clear();
1089
+ this.cachedStatus.clear();
1090
+ this.cachedRooms.clear();
1091
+ this.deviceModels.clear();
1092
+ this.deviceOwnerIds.clear();
1093
+ this.deviceBindDomains.clear();
1094
+ this.session = null;
1095
+ }
1096
+ }