matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
483 lines (482 loc) • 22.1 kB
JavaScript
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;
}
}