matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
623 lines (535 loc) • 24.2 kB
text/typescript
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;
}
}