UNPKG

matterbridge-roborock-vacuum-plugin

Version:
483 lines (482 loc) 22.1 kB
import assert from 'node:assert'; import { debugStringify } from 'matterbridge/logger'; import { NotifyMessageTypes } from './notifyMessageTypes.js'; import { clearInterval } from 'node:timers'; import { RoborockAuthenticateApi, RoborockIoTApi, MessageProcessor, Protocol, RequestMessage, ResponseMessage, MapInfo, } from './roborockCommunication/index.js'; export default class RoborockService { loginApi; logger; iotApiFactory; iotApi; userdata; deviceNotify; messageClient; remoteDevices = new Set(); messageProcessorMap = new Map(); ipMap = new Map(); localClientMap = new Map(); mqttAlwaysOnDevices = new Map(); clientManager; refreshInterval; requestDeviceStatusInterval; supportedAreas = new Map(); supportedRoutines = new Map(); selectedAreas = new Map(); supportedAreaIndexMaps = new Map(); vacuumNeedAPIV3 = ['roborock.vacuum.ss07']; constructor(authenticateApiSupplier = (logger) => new RoborockAuthenticateApi(logger), iotApiSupplier = (logger, ud) => new RoborockIoTApi(ud, logger), refreshInterval, clientManager, logger) { this.logger = logger; this.loginApi = authenticateApiSupplier(logger); this.iotApiFactory = iotApiSupplier; this.refreshInterval = refreshInterval; this.clientManager = clientManager; } async loginWithPassword(username, password, loadSavedUserData, savedUserData) { let userdata = await loadSavedUserData(); if (!userdata) { this.logger.debug('No saved user data found, logging in with password'); userdata = await this.loginApi.loginWithPassword(username, password); await savedUserData(userdata); } else { this.logger.debug('Using saved user data for login', debugStringify(userdata)); userdata = await this.loginApi.loginWithUserData(username, userdata); } return this.auth(userdata); } getMessageProcessor(duid) { const messageProcessor = this.messageProcessorMap.get(duid); if (!messageProcessor) { this.logger.error('MessageApi is not initialized.'); } return messageProcessor; } setSelectedAreas(duid, selectedAreas) { this.logger.debug('RoborockService - setSelectedAreas', selectedAreas); const roomIds = selectedAreas.map((areaId) => this.supportedAreaIndexMaps.get(duid)?.getRoomId(areaId)) ?? []; this.logger.debug('RoborockService - setSelectedAreas - roomIds', roomIds); this.selectedAreas.set(duid, roomIds.filter((id) => id !== undefined).map((id) => id)); } getSelectedAreas(duid) { return this.selectedAreas.get(duid) ?? []; } setSupportedAreas(duid, supportedAreas) { this.supportedAreas.set(duid, supportedAreas); } setSupportedAreaIndexMap(duid, indexMap) { this.supportedAreaIndexMaps.set(duid, indexMap); } setSupportedScenes(duid, routineAsRooms) { this.supportedRoutines.set(duid, routineAsRooms); } getSupportedAreas(duid) { return this.supportedAreas.get(duid); } getSupportedAreasIndexMap(duid) { return this.supportedAreaIndexMaps.get(duid); } async getCleanModeData(duid) { this.logger.notice('RoborockService - getCleanModeData'); const data = await this.getMessageProcessor(duid)?.getCleanModeData(duid); if (!data) { throw new Error('Failed to retrieve clean mode data'); } return data; } async getRoomIdFromMap(duid) { const data = (await this.customGet(duid, new RequestMessage({ method: 'get_map_v1' }))); return data?.vacuumRoom; } async getMapInformation(duid) { this.logger.debug('RoborockService - getMapInformation', duid); assert(this.messageClient !== undefined); return this.messageClient.get(duid, new RequestMessage({ method: 'get_multi_maps_list' })).then((response) => { this.logger.debug('RoborockService - getMapInformation response', debugStringify(response ?? [])); return response ? new MapInfo(response[0]) : undefined; }); } async changeCleanMode(duid, { suctionPower, waterFlow, distance_off, mopRoute }) { this.logger.notice('RoborockService - changeCleanMode'); return this.getMessageProcessor(duid)?.changeCleanMode(duid, suctionPower, waterFlow, mopRoute ?? 0, distance_off); } async startClean(duid) { const supportedRooms = this.supportedAreas.get(duid) ?? []; const supportedRoutines = this.supportedRoutines.get(duid) ?? []; const selected = this.selectedAreas.get(duid) ?? []; this.logger.debug('RoborockService - begin cleaning', debugStringify({ duid, supportedRooms, supportedRoutines, selected })); if (supportedRoutines.length === 0) { if (selected.length == supportedRooms.length || selected.length === 0 || supportedRooms.length === 0) { this.logger.debug('RoborockService - startGlobalClean'); this.getMessageProcessor(duid)?.startClean(duid); } else { this.logger.debug('RoborockService - startRoomClean', debugStringify({ duid, selected })); return this.getMessageProcessor(duid)?.startRoomClean(duid, selected, 1); } } else { const rooms = selected.filter((slt) => supportedRooms.some((a) => a.areaId == slt)); const rt = selected.filter((slt) => supportedRoutines.some((a) => a.areaId == slt)); if (rt.length > 1) { this.logger.warn('RoborockService - Multiple routines selected, which is not supported.', debugStringify({ duid, rt })); } else if (rt.length === 1) { this.logger.debug('RoborockService - startScene', debugStringify({ duid, rooms })); await this.iotApi?.startScene(rt[0]); return; } else if (rooms.length == supportedRooms.length || rooms.length === 0 || supportedRooms.length === 0) { this.logger.debug('RoborockService - startGlobalClean'); this.getMessageProcessor(duid)?.startClean(duid); } else if (rooms.length > 0) { this.logger.debug('RoborockService - startRoomClean', debugStringify({ duid, rooms })); return this.getMessageProcessor(duid)?.startRoomClean(duid, rooms, 1); } else { this.logger.warn('RoborockService - something goes wrong.', debugStringify({ duid, rooms, rt, selected, supportedRooms, supportedRoutines })); return; } } } async pauseClean(duid) { this.logger.debug('RoborockService - pauseClean'); await this.getMessageProcessor(duid)?.pauseClean(duid); } async stopAndGoHome(duid) { this.logger.debug('RoborockService - stopAndGoHome'); await this.getMessageProcessor(duid)?.gotoDock(duid); } async resumeClean(duid) { this.logger.debug('RoborockService - resumeClean'); await this.getMessageProcessor(duid)?.resumeClean(duid); } async playSoundToLocate(duid) { this.logger.debug('RoborockService - findMe'); await this.getMessageProcessor(duid)?.findMyRobot(duid); } async customGet(duid, request) { this.logger.debug('RoborockService - customSend-message', request.method, request.params, request.secure); return this.getMessageProcessor(duid)?.getCustomMessage(duid, request); } async customSend(duid, request) { return this.getMessageProcessor(duid)?.sendCustomMessage(duid, request); } async getCustomAPI(url) { this.logger.debug('RoborockService - getCustomAPI', url); assert(this.iotApi !== undefined); try { return await this.iotApi.getCustom(url); } catch (error) { this.logger.error(`Failed to get custom API with url ${url}: ${error ? debugStringify(error) : 'undefined'}`); return { result: undefined, error: `Failed to get custom API with url ${url}` }; } } stopService() { if (this.messageClient) { this.messageClient.disconnect(); this.messageClient = undefined; } if (this.localClientMap.size > 0) { for (const [duid, client] of this.localClientMap.entries()) { this.logger.debug('Disconnecting local client for device', duid); client.disconnect(); this.localClientMap.delete(duid); this.logger.debug('Local client disconnected for device', duid); } } if (this.messageProcessorMap.size > 0) { for (const [duid] of this.messageProcessorMap.entries()) { this.logger.debug('Disconnecting message processor for device', duid); this.messageProcessorMap.delete(duid); this.logger.debug('Message processor disconnected for device', duid); } } if (this.requestDeviceStatusInterval) { clearInterval(this.requestDeviceStatusInterval); this.requestDeviceStatusInterval = undefined; } } setDeviceNotify(callback) { this.deviceNotify = callback; } activateDeviceNotify(device) { const self = this; this.logger.debug('Requesting device info for device', device.duid); const messageProcessor = this.getMessageProcessor(device.duid); this.requestDeviceStatusInterval = setInterval(async () => { if (messageProcessor) { await messageProcessor.getDeviceStatus(device.duid).then((response) => { if (self.deviceNotify && response) { const message = { duid: device.duid, ...response.errorStatus, ...response.message }; self.logger.debug('Socket - Device status update', debugStringify(message)); self.deviceNotify(NotifyMessageTypes.LocalMessage, message); } }); } else { self.logger.error('Local client not initialized'); } }, this.refreshInterval * 1000); } activateDeviceNotifyOverMQTT(device) { const self = this; this.logger.notice('Requesting device info for device over MQTT', device.duid); const messageProcessor = this.getMessageProcessor(device.duid); this.requestDeviceStatusInterval = setInterval(async () => { if (messageProcessor) { await messageProcessor.getDeviceStatusOverMQTT(device.duid).then((response) => { if (self.deviceNotify && response) { const message = { duid: device.duid, ...response.errorStatus, ...response.message }; self.logger.debug('MQTT - Device status update', debugStringify(message)); self.deviceNotify(NotifyMessageTypes.LocalMessage, message); } }); } else { self.logger.error('Local client not initialized'); } }, this.refreshInterval * 500); } async listDevices(username) { assert(this.iotApi !== undefined); assert(this.userdata !== undefined); const homeDetails = await this.loginApi.getHomeDetails(); if (!homeDetails) { throw new Error('Failed to retrieve the home details'); } const homeData = (await this.iotApi.getHome(homeDetails.rrHomeId)); if (!homeData) { return []; } const scenes = (await this.iotApi.getScenes(homeDetails.rrHomeId)) ?? []; const products = new Map(); homeData.products.forEach((p) => products.set(p.id, p.model)); if (homeData.products.some((p) => this.vacuumNeedAPIV3.includes(p.model))) { this.logger.debug('Using v3 API for home data retrieval'); const homeDataV3 = await this.iotApi.getHomev3(homeDetails.rrHomeId); if (!homeDataV3) { throw new Error('Failed to retrieve the home data from v3 API'); } homeData.devices = [...homeData.devices, ...homeDataV3.devices.filter((d) => !homeData.devices.some((x) => x.duid === d.duid))]; homeData.receivedDevices = [...homeData.receivedDevices, ...homeDataV3.receivedDevices.filter((d) => !homeData.receivedDevices.some((x) => x.duid === d.duid))]; } if (homeData.rooms.length === 0) { const homeDataV2 = await this.iotApi.getHomev2(homeDetails.rrHomeId); if (homeDataV2 && homeDataV2.rooms && homeDataV2.rooms.length > 0) { homeData.rooms = homeDataV2.rooms; } else { const homeDataV3 = await this.iotApi.getHomev3(homeDetails.rrHomeId); if (homeDataV3 && homeDataV3.rooms && homeDataV3.rooms.length > 0) { homeData.rooms = homeDataV3.rooms; } } } const devices = [...homeData.devices, ...homeData.receivedDevices]; const result = devices.map((device) => { return { ...device, rrHomeId: homeDetails.rrHomeId, rooms: homeData.rooms, localKey: device.localKey, pv: device.pv, serialNumber: device.sn, scenes: scenes.filter((sc) => sc.param && JSON.parse(sc.param).action.items.some((x) => x.entityId == device.duid)), data: { id: device.duid, firmwareVersion: device.fv, serialNumber: device.sn, model: homeData.products.find((p) => p.id === device.productId)?.model, category: homeData.products.find((p) => p.id === device.productId)?.category, batteryLevel: device.deviceStatus?.[Protocol.battery] ?? 100, }, store: { username: username, userData: this.userdata, localKey: device.localKey, pv: device.pv, model: products.get(device.productId), }, }; }); return result; } async getHomeDataForUpdating(homeid) { assert(this.iotApi !== undefined); assert(this.userdata !== undefined); const homeData = await this.iotApi.getHomev2(homeid); if (!homeData) { throw new Error('Failed to retrieve the home data'); } const products = new Map(); homeData.products.forEach((p) => products.set(p.id, p.model)); const devices = homeData.devices.length > 0 ? homeData.devices : homeData.receivedDevices; if (homeData.rooms.length === 0) { const homeDataV3 = await this.iotApi.getHomev3(homeid); if (homeDataV3 && homeDataV3.rooms && homeDataV3.rooms.length > 0) { homeData.rooms = homeDataV3.rooms; } else { const homeDataV1 = await this.iotApi.getHome(homeid); if (homeDataV1 && homeDataV1.rooms && homeDataV1.rooms.length > 0) { homeData.rooms = homeDataV1.rooms; } } } const dvs = devices.map((device) => { return { ...device, rrHomeId: homeid, rooms: homeData.rooms, serialNumber: device.sn, data: { id: device.duid, firmwareVersion: device.fv, serialNumber: device.sn, model: homeData.products.find((p) => p.id === device.productId)?.model, category: homeData.products.find((p) => p.id === device.productId)?.category, batteryLevel: device.deviceStatus?.[Protocol.battery] ?? 100, }, store: { userData: this.userdata, localKey: device.localKey, pv: device.pv, model: products.get(device.productId), }, }; }); return { ...homeData, devices: dvs, }; } async getScenes(homeId) { assert(this.iotApi !== undefined); return this.iotApi.getScenes(homeId); } async startScene(sceneId) { assert(this.iotApi !== undefined); return this.iotApi.startScene(sceneId); } async getRoomMappings(duid) { if (!this.messageClient) { this.logger.warn('messageClient not initialized. Waititing for next execution'); return Promise.resolve(undefined); } return this.messageClient.get(duid, new RequestMessage({ method: 'get_room_mapping', secure: this.isRequestSecure(duid) })); } async initializeMessageClient(username, device, userdata) { if (this.clientManager === undefined) { this.logger.error('ClientManager not initialized'); return; } const self = this; this.messageClient = this.clientManager.get(username, userdata); this.messageClient.registerDevice(device.duid, device.localKey, device.pv, undefined); this.messageClient.registerMessageListener({ onMessage: (message) => { if (message instanceof ResponseMessage) { const duid = message.duid; if (message.contain(Protocol.battery)) return; if (duid && self.deviceNotify) { self.deviceNotify(NotifyMessageTypes.CloudMessage, message); } } if (message instanceof ResponseMessage && message.contain(Protocol.hello_response)) { const dps = message.dps[Protocol.hello_response]; const result = dps.result; self.messageClient?.updateNonce(message.duid, result.nonce); } }, }); this.messageClient.connect(); while (!this.messageClient.isConnected()) { await this.sleep(500); } this.logger.debug('MessageClient connected'); } async initializeMessageClientForLocal(device) { this.logger.debug('Begin get local ip'); if (this.messageClient === undefined) { this.logger.error('messageClient not initialized'); return false; } const self = this; const messageProcessor = new MessageProcessor(this.messageClient); messageProcessor.injectLogger(this.logger); messageProcessor.registerListener({ onError: (message) => { if (self.deviceNotify) { self.deviceNotify(NotifyMessageTypes.ErrorOccurred, { duid: device.duid, errorCode: message }); } }, onBatteryUpdate: (percentage) => { if (self.deviceNotify) { self.deviceNotify(NotifyMessageTypes.BatteryUpdate, { duid: device.duid, percentage }); } }, onStatusChanged: () => { }, }); this.messageProcessorMap.set(device.duid, messageProcessor); this.logger.debug('Checking if device supports local connection', device.pv, device.data.model, device.duid); if (device.pv === 'B01') { this.logger.warn('Device does not support local connection', device.duid); this.mqttAlwaysOnDevices.set(device.duid, true); return true; } else { this.mqttAlwaysOnDevices.set(device.duid, false); } this.logger.debug('Local device', device.duid); let localIp = this.ipMap.get(device.duid); try { if (!localIp) { this.logger.debug('Requesting network info for device', device.duid); const networkInfo = await messageProcessor.getNetworkInfo(device.duid); if (!networkInfo || !networkInfo.ip) { this.logger.error('Failed to retrieve network info for device', device.duid, 'Network info:', networkInfo); return false; } this.logger.debug('Network ip for device', device.duid, 'is', networkInfo.ip); localIp = networkInfo.ip; } if (localIp) { this.logger.debug('initializing the local connection for this client towards ' + localIp); const localClient = this.messageClient.registerClient(device.duid, localIp); localClient.connect(); let count = 0; while (!localClient.isConnected() && count < 20) { this.logger.debug('Keep waiting for local client to connect'); count++; await this.sleep(500); } if (!localClient.isConnected()) { throw new Error('Local client did not connect after 10 attempts, something is wrong'); } this.ipMap.set(device.duid, localIp); this.localClientMap.set(device.duid, localClient); this.logger.debug('LocalClient connected'); } } catch (error) { this.logger.error('Error requesting network info', error); return false; } return true; } sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } auth(userdata) { this.userdata = userdata; this.iotApi = this.iotApiFactory(this.logger, userdata); return userdata; } isRequestSecure(duid) { return this.mqttAlwaysOnDevices.get(duid) ?? false; } }