matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
466 lines (402 loc) • 17.1 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,
} 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;
}
}