UNPKG

matterbridge-roborock-vacuum-plugin

Version:
466 lines (402 loc) 17.1 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, } from './roborockCommunication/index.js'; import type { AbstractMessageHandler, AbstractMessageListener, AbstractConnectionListener, BatteryMessage, DeviceErrorMessage, DeviceStatusNotify, } from './roborockCommunication/index.js'; import { ServiceArea } from 'matterbridge/matter/clusters'; import { LocalNetworkClient } from './roborockCommunication/broadcast/client/LocalNetworkClient.js'; export type Factory<A, T> = (logger: AnsiLogger, arg: A) => T; 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>(); messageProcessor: MessageProcessor | undefined; ip: string | undefined; localClient: Client | undefined; 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[]>(); 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): Promise<UserData> { const userdata = await this.loginApi.loginWithPassword(username, password); return this.auth(userdata); } public getMessageProcessor(): MessageProcessor | undefined { if (!this.messageProcessor) { this.logger.error('MessageApi is not initialized.'); } return this.messageProcessor; } public setSelectedAreas(duid: string, selectedAreas: number[]): void { this.logger.debug('RoborockService - setSelectedAreas', selectedAreas); this.selectedAreas.set(duid, selectedAreas); } public setSupportedAreas(duid: string, supportedAreas: ServiceArea.Area[]): void { this.supportedAreas.set(duid, supportedAreas); } 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 async getCleanModeData(duid: string): Promise<{ suctionPower: number; waterFlow: number; distance_off: number; mopRoute: number }> { this.logger.notice('RoborockService - getCleanModeData'); const data = await this.messageProcessor?.getCleanModeData(duid); if (!data) { throw new Error('Failed to retrieve clean mode data'); } return data; } public async changeCleanMode( duid: string, { suctionPower, waterFlow, distance_off, mopRoute }: { suctionPower: number; waterFlow: number; distance_off: number; mopRoute: number }, ): Promise<void> { this.logger.notice('RoborockService - changeCleanMode'); return this.messageProcessor?.changeCleanMode(duid, suctionPower, waterFlow, mopRoute, 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()?.startClean(duid); } else { this.logger.debug('RoborockService - startRoomClean', debugStringify({ duid, selected })); return this.messageProcessor?.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()?.startClean(duid); } else if (rooms.length > 0) { /** * If there are rooms selected */ this.logger.debug('RoborockService - startRoomClean', debugStringify({ duid, rooms })); return this.messageProcessor?.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()?.pauseClean(duid); } public async stopAndGoHome(duid: string): Promise<void> { this.logger.debug('RoborockService - stopAndGoHome'); await this.getMessageProcessor()?.gotoDock(duid); } public async resumeClean(duid: string): Promise<void> { this.logger.debug('RoborockService - resumeClean'); await this.getMessageProcessor()?.resumeClean(duid); } public async playSoundToLocate(duid: string): Promise<void> { this.logger.debug('RoborockService - findMe'); await this.getMessageProcessor()?.findMyRobot(duid); } public async customGet(duid: string, method: string): Promise<unknown> { this.logger.debug('RoborockService - customSend-message', method); return this.getMessageProcessor()?.getCustomMessage(duid, new RequestMessage({ method })); } public async customGetInSecure(duid: string, method: string): Promise<unknown> { this.logger.debug('RoborockService - customGetInSecure-message', method); return this.getMessageProcessor()?.getCustomMessage(duid, new RequestMessage({ method, secure: true })); } public async customSend(duid: string, request: RequestMessage): Promise<void> { return this.getMessageProcessor()?.sendCustomMessage(duid, request); } public stopService(): void { if (this.messageClient) { this.messageClient.disconnect(); this.messageClient = undefined; } if (this.localClient) { this.localClient.disconnect(); this.localClient = undefined; } if (this.messageProcessor) { this.messageProcessor = undefined; } if (this.requestDeviceStatusInterval) { clearInterval(this.requestDeviceStatusInterval); this.requestDeviceStatusInterval = undefined; } } public setDeviceNotify(callback: (messageSource: NotifyMessageTypes, homeData: unknown) => Promise<void>): void { this.deviceNotify = callback; } public async activateDeviceNotify(device: Device): Promise<void> { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this.logger.debug('Requesting device info for device', device.duid); this.requestDeviceStatusInterval = setInterval(async () => { if (this.messageProcessor) { await this.messageProcessor.getDeviceStatus(device.duid).then((response: DeviceStatus) => { if (self.deviceNotify) { const message: DeviceStatusNotify = { duid: device.duid, ...response.errorStatus, ...response.message } as DeviceStatusNotify; self.logger.debug('Device status update', debugStringify(message)); self.deviceNotify(NotifyMessageTypes.LocalMessage, message); } }); } else { self.logger.error('Local client not initialized'); } }, this.refreshInterval * 1000); } 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)); const devices: Device[] = [...homeData.devices, ...homeData.receivedDevices]; // homeData.devices.length > 0 ? 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; 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 getRoomMappings(duid: string): Promise<number[][]> | undefined { if (!this.messageClient) { this.logger.warn('messageClient not initialized. Waititing for next execution'); return undefined; } return this.messageClient.get(duid, new RequestMessage({ method: 'get_room_mapping' })); } 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); this.messageClient.registerConnectionListener({ onConnected: () => { self.logger.notice('Connected to MQTT broker'); }, onDisconnected: () => { self.logger.notice('Disconnected from MQTT broker'); }, onError: (message: string) => { self.logger.error('Error from MQTT broker', message); }, } as AbstractConnectionListener); 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); } } }, } as AbstractMessageListener); this.messageClient.connect(); while (!this.messageClient.isConnected()) { await this.sleep(500); } this.logger.debug('MessageClient connected'); } public async initializeMessageClientForLocal(device: Device): Promise<void> { this.logger.debug('Begin get local ip'); if (this.messageClient === undefined) { this.logger.error('messageClient not initialized'); return; } // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; this.messageProcessor = new MessageProcessor(this.messageClient); this.messageProcessor.injectLogger(this.logger); this.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); } }, } as AbstractMessageHandler); this.logger.debug('Local device', device.duid); try { if (!this.ip) { this.logger.debug('Requesting network info for device', device.duid); const networkInfo = await this.messageProcessor.getNetworkInfo(device.duid); this.ip = networkInfo.ip; } if (this.ip) { this.logger.debug('initializing the local connection for this client towards ' + this.ip); this.localClient = this.messageClient.registerClient(device.duid, this.ip) as LocalNetworkClient; this.localClient.connect(); let count = 0; while (!this.localClient.isConnected() && count < 20) { this.logger.debug('Keep waiting for local client to connect'); count++; await this.sleep(500); } if (!this.localClient.isConnected()) { throw new Error('Local client did not connect after 10 attempts, something is wrong'); } this.logger.debug('LocalClient connected'); } } catch (error) { this.logger.error('Error requesting network info', error); } } 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; } }