UNPKG

matterbridge-roborock-vacuum-plugin

Version:
623 lines (535 loc) 24.2 kB
import assert from 'node:assert'; import { AnsiLogger, debugStringify } from 'matterbridge/logger'; import ClientManager from './clientManager.js'; import { NotifyMessageTypes } from './notifyMessageTypes.js'; import { clearInterval } from 'node:timers'; import { RoborockAuthenticateApi, UserData, RoborockIoTApi, ClientRouter, MessageProcessor, Client, Device, DeviceStatus, Home, Protocol, RequestMessage, VacuumErrorCode, ResponseMessage, Scene, SceneParam, MapInfo, } from './roborockCommunication/index.js'; import type { AbstractMessageHandler, AbstractMessageListener, BatteryMessage, DeviceErrorMessage, DeviceStatusNotify, MultipleMap } from './roborockCommunication/index.js'; import { ServiceArea } from 'matterbridge/matter/clusters'; import { LocalNetworkClient } from './roborockCommunication/broadcast/client/LocalNetworkClient.js'; import { RoomIndexMap } from './model/roomIndexMap.js'; import { DpsPayload } from './roborockCommunication/broadcast/model/dps.js'; import { CleanModeSetting } from './behaviors/roborock.vacuum/default/default.js'; export type Factory<A, T> = (logger: AnsiLogger, arg: A) => T; interface MapRoomResponse { vacuumRoom?: number; } interface Security { nonce: number; } export default class RoborockService { private loginApi: RoborockAuthenticateApi; private logger: AnsiLogger; private readonly iotApiFactory: Factory<UserData, RoborockIoTApi>; private iotApi?: RoborockIoTApi; private userdata?: UserData; deviceNotify?: (messageSource: NotifyMessageTypes, homeData: unknown) => void; messageClient: ClientRouter | undefined; remoteDevices = new Set<string>(); messageProcessorMap = new Map<string, MessageProcessor>(); ipMap = new Map<string, string>(); localClientMap = new Map<string, Client>(); mqttAlwaysOnDevices = new Map<string, boolean>(); clientManager: ClientManager; refreshInterval: number; requestDeviceStatusInterval: NodeJS.Timeout | undefined; // These are properties that are used to store the state of the device private supportedAreas = new Map<string, ServiceArea.Area[]>(); private supportedRoutines = new Map<string, ServiceArea.Area[]>(); private selectedAreas = new Map<string, number[]>(); private supportedAreaIndexMaps = new Map<string, RoomIndexMap>(); private readonly vacuumNeedAPIV3 = ['roborock.vacuum.ss07']; constructor( authenticateApiSupplier: Factory<void, RoborockAuthenticateApi> = (logger) => new RoborockAuthenticateApi(logger), iotApiSupplier: Factory<UserData, RoborockIoTApi> = (logger, ud) => new RoborockIoTApi(ud, logger), refreshInterval: number, clientManager: ClientManager, logger: AnsiLogger, ) { this.logger = logger; this.loginApi = authenticateApiSupplier(logger); this.iotApiFactory = iotApiSupplier; this.refreshInterval = refreshInterval; this.clientManager = clientManager; } public async loginWithPassword( username: string, password: string, loadSavedUserData: () => Promise<UserData | undefined>, savedUserData: (userData: UserData) => Promise<void>, ): Promise<UserData> { 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); } public getMessageProcessor(duid: string): MessageProcessor | undefined { const messageProcessor = this.messageProcessorMap.get(duid); if (!messageProcessor) { this.logger.error('MessageApi is not initialized.'); } return messageProcessor; } public setSelectedAreas(duid: string, selectedAreas: number[]): void { 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), ); } public getSelectedAreas(duid: string): number[] { return this.selectedAreas.get(duid) ?? []; } public setSupportedAreas(duid: string, supportedAreas: ServiceArea.Area[]): void { this.supportedAreas.set(duid, supportedAreas); } public setSupportedAreaIndexMap(duid: string, indexMap: RoomIndexMap): void { this.supportedAreaIndexMaps.set(duid, indexMap); } public setSupportedScenes(duid: string, routineAsRooms: ServiceArea.Area[]) { this.supportedRoutines.set(duid, routineAsRooms); } public getSupportedAreas(duid: string): ServiceArea.Area[] | undefined { return this.supportedAreas.get(duid); } public getSupportedAreasIndexMap(duid: string): RoomIndexMap | undefined { return this.supportedAreaIndexMaps.get(duid); } public async getCleanModeData(duid: string): Promise<CleanModeSetting> { 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; } public async getRoomIdFromMap(duid: string): Promise<number | undefined> { const data = (await this.customGet(duid, new RequestMessage({ method: 'get_map_v1' }))) as MapRoomResponse; return data?.vacuumRoom; } public async getMapInformation(duid: string): Promise<MapInfo | undefined> { this.logger.debug('RoborockService - getMapInformation', duid); assert(this.messageClient !== undefined); return this.messageClient.get<MultipleMap[] | undefined>(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; }); } public async changeCleanMode(duid: string, { suctionPower, waterFlow, distance_off, mopRoute }: CleanModeSetting): Promise<void> { this.logger.notice('RoborockService - changeCleanMode'); return this.getMessageProcessor(duid)?.changeCleanMode(duid, suctionPower, waterFlow, mopRoute ?? 0, distance_off); } public async startClean(duid: string): Promise<void> { 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: ServiceArea.Area) => a.areaId == slt)); const rt = selected.filter((slt) => supportedRoutines.some((a: ServiceArea.Area) => a.areaId == slt)); /** * If multiple routines are selected, we log a warning. and continue with global clean */ 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) { /** * If no rooms are selected, or all selected rooms match the supported rooms, */ this.logger.debug('RoborockService - startGlobalClean'); this.getMessageProcessor(duid)?.startClean(duid); } else if (rooms.length > 0) { /** * If there are rooms selected */ 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; } } } public async pauseClean(duid: string): Promise<void> { this.logger.debug('RoborockService - pauseClean'); await this.getMessageProcessor(duid)?.pauseClean(duid); } public async stopAndGoHome(duid: string): Promise<void> { this.logger.debug('RoborockService - stopAndGoHome'); await this.getMessageProcessor(duid)?.gotoDock(duid); } public async resumeClean(duid: string): Promise<void> { this.logger.debug('RoborockService - resumeClean'); await this.getMessageProcessor(duid)?.resumeClean(duid); } public async playSoundToLocate(duid: string): Promise<void> { this.logger.debug('RoborockService - findMe'); await this.getMessageProcessor(duid)?.findMyRobot(duid); } public async customGet(duid: string, request: RequestMessage): Promise<unknown> { this.logger.debug('RoborockService - customSend-message', request.method, request.params, request.secure); return this.getMessageProcessor(duid)?.getCustomMessage(duid, request); } public async customSend(duid: string, request: RequestMessage): Promise<void> { return this.getMessageProcessor(duid)?.sendCustomMessage(duid, request); } public async getCustomAPI(url: string): Promise<unknown> { 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}` }; } } public stopService(): void { 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; } } public setDeviceNotify(callback: (messageSource: NotifyMessageTypes, homeData: unknown) => Promise<void>): void { this.deviceNotify = callback; } public activateDeviceNotify(device: Device): void { // eslint-disable-next-line @typescript-eslint/no-this-alias 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: DeviceStatus | undefined) => { if (self.deviceNotify && response) { const message = { duid: device.duid, ...response.errorStatus, ...response.message } as DeviceStatusNotify; 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); } public activateDeviceNotifyOverMQTT(device: Device): void { // eslint-disable-next-line @typescript-eslint/no-this-alias 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: DeviceStatus | undefined) => { if (self.deviceNotify && response) { const message = { duid: device.duid, ...response.errorStatus, ...response.message } as DeviceStatusNotify; 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); } public async listDevices(username: string): Promise<Device[]> { 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)) as Home; if (!homeData) { return []; } const scenes = (await this.iotApi.getScenes(homeDetails.rrHomeId)) ?? []; const products = new Map<string, string>(); 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))]; } // Try to get rooms from v2 API if rooms are empty 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: Device[] = [...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) as SceneParam).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 as UserData, localKey: device.localKey, pv: device.pv, model: products.get(device.productId), }, }; }) as Device[]; return result; } public async getHomeDataForUpdating(homeid: number): Promise<Home> { 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<string, string>(); homeData.products.forEach((p) => products.set(p.id, p.model)); const devices: Device[] = 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 as UserData, localKey: device.localKey, pv: device.pv, model: products.get(device.productId), }, }; }) as Device[]; return { ...homeData, devices: dvs, }; } public async getScenes(homeId: number): Promise<Scene[] | undefined> { assert(this.iotApi !== undefined); return this.iotApi.getScenes(homeId); } public async startScene(sceneId: number): Promise<unknown> { assert(this.iotApi !== undefined); return this.iotApi.startScene(sceneId); } public async getRoomMappings(duid: string): Promise<number[][] | undefined> { 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) })); // return await this.getMessageProcessor(duid)?.getRooms(duid); } public async initializeMessageClient(username: string, device: Device, userdata: UserData): Promise<void> { if (this.clientManager === undefined) { this.logger.error('ClientManager not initialized'); return; } // eslint-disable-next-line @typescript-eslint/no-this-alias 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: ResponseMessage) => { if (message instanceof ResponseMessage) { const duid = message.duid; // ignore battery updates here 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] as DpsPayload; const result = dps.result as Security; self.messageClient?.updateNonce(message.duid, result.nonce); } }, } as AbstractMessageListener); this.messageClient.connect(); while (!this.messageClient.isConnected()) { await this.sleep(500); } this.logger.debug('MessageClient connected'); } public async initializeMessageClientForLocal(device: Device): Promise<boolean> { this.logger.debug('Begin get local ip'); if (this.messageClient === undefined) { this.logger.error('messageClient not initialized'); return false; } // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const messageProcessor = new MessageProcessor(this.messageClient); messageProcessor.injectLogger(this.logger); messageProcessor.registerListener({ onError: (message: VacuumErrorCode) => { if (self.deviceNotify) { self.deviceNotify(NotifyMessageTypes.ErrorOccurred, { duid: device.duid, errorCode: message } as DeviceErrorMessage); } }, onBatteryUpdate: (percentage: number) => { if (self.deviceNotify) { self.deviceNotify(NotifyMessageTypes.BatteryUpdate, { duid: device.duid, percentage } as BatteryMessage); } }, onStatusChanged: () => { // status: DeviceStatus // if (self.deviceNotify) { // const message: DeviceStatusNotify = { duid: device.duid, ...status.errorStatus, ...status.message } as DeviceStatusNotify; // self.logger.debug('Device status update', debugStringify(message)); // self.deviceNotify(NotifyMessageTypes.LocalMessage, message); // } }, } as AbstractMessageHandler); 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) as LocalNetworkClient; 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; } private sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } private auth(userdata: UserData): UserData { this.userdata = userdata; this.iotApi = this.iotApiFactory(this.logger, userdata); return userdata; } private isRequestSecure(duid: string): boolean { return this.mqttAlwaysOnDevices.get(duid) ?? false; } }