matterbridge-xiaomi-roborock
Version:
Matterbridge Xiaomi Roborock Plugin
335 lines (334 loc) • 17.4 kB
JavaScript
import { RoboticVacuumCleaner } from 'matterbridge/devices';
import { firstValueFrom, mergeMap, Subject, takeUntil } from 'rxjs';
import { PowerSource, RvcRunMode, RvcCleanMode, RvcOperationalState, ServiceArea } from 'matterbridge/matter/clusters';
import { applyConfigDefaults } from './services/config_service.js';
import { DeviceManager } from './services/device_manager.js';
import { getLogger } from './utils/logger.js';
import { findSpeedModes } from './utils/find_speed_modes.js';
import { MODELS } from './models/models.js';
const SUPPORTED_MODES = [
{ label: 'Idle', mode: 1, modeTags: [{ value: RvcRunMode.ModeTag.Idle }] },
{ label: 'Cleaning', mode: 2, modeTags: [{ value: RvcRunMode.ModeTag.Cleaning }] },
{ label: 'Deprecated Idle', mode: 0, modeTags: [{ value: RvcRunMode.ModeTag.Idle }] },
];
const SUPPORTED_OPERATIONAL_STATES = [
{ operationalStateId: RvcOperationalState.OperationalState.Docked },
{ operationalStateId: RvcOperationalState.OperationalState.SeekingCharger },
{ operationalStateId: RvcOperationalState.OperationalState.Charging },
{ operationalStateId: RvcOperationalState.OperationalState.Running },
{ operationalStateId: RvcOperationalState.OperationalState.Stopped },
{ operationalStateId: RvcOperationalState.OperationalState.Paused },
{ operationalStateId: RvcOperationalState.OperationalState.Error },
];
export class VacuumDeviceAccessory {
config;
log;
deviceManager;
stop$ = new Subject();
endpoint;
serviceAreas = [];
modelSpeeds = MODELS.default[0];
constructor(config, logger) {
this.config = applyConfigDefaults(config);
this.log = getLogger(logger, this.config);
this.deviceManager = new DeviceManager(this.log, this.config);
}
async initializeMatterbridgeEndpoint() {
this.log.info(`Waiting for the connection to the vacuum to be established...`);
await firstValueFrom(this.deviceManager.deviceConnected$);
this.log.info(`Connected to device!`);
const serialNumber = await this.deviceManager.device.getSerialNumber().catch((error) => {
this.log.warn(`Failed to retrieve serial number: ${error}`);
return 'Unknown';
});
const deviceInfo = await this.deviceManager.device.getDeviceInfo().catch((error) => {
this.log.warn(`Failed to retrieve device info: ${error}`);
return { fw_ver: 'Unknown' };
});
this.log.info(`Serial number: ${serialNumber}`);
this.log.info(`Firmware: ${deviceInfo.fw_ver}`);
this.modelSpeeds = findSpeedModes(this.deviceManager.model, deviceInfo.fw_ver);
const supportedCleanModes = this.supportedCleanModes;
this.serviceAreas = await this.getServiceAreas().catch((error) => {
this.log.warn(`Failed to retrieve service areas: ${error}`);
return [];
});
this.endpoint = new RoboticVacuumCleaner(this.config.name, serialNumber, 'server', SUPPORTED_MODES[0].mode, SUPPORTED_MODES, supportedCleanModes[0].mode, supportedCleanModes, undefined, undefined, RvcOperationalState.OperationalState.Docked, SUPPORTED_OPERATIONAL_STATES, this.serviceAreas.length > 0 ? this.serviceAreas : undefined, [], this.serviceAreas[0]?.areaId);
this.endpoint.vendorName = 'Xiaomi';
this.endpoint.productName = this.deviceManager.model;
this.endpoint.softwareVersionString = deviceInfo.fw_ver;
this.endpoint.productUrl = 'https://github.com/afharo/matterbridge-xiaomi-roborock';
this.endpoint.hardwareVersionString = this.deviceManager.model;
this.endpoint.lifecycle.destroying.on(() => {
this.deviceManager.stop();
});
this.endpoint.addCommandHandler('RvcCleanMode.changeToMode', async (data) => {
const newCleanMode = supportedCleanModes[data.request.newMode - 1];
await this.deviceManager.device.changeFanSpeed(newCleanMode.miLevels.vacuum);
if (typeof newCleanMode.miLevels.mop === 'number') {
await this.deviceManager.device.setWaterBoxMode(newCleanMode.miLevels.mop);
}
});
this.endpoint.addCommandHandler('RvcRunMode.changeToMode', async (data) => {
switch (data.request.newMode) {
case 1:
break;
case 2: {
const selectedAreas = this.selectedAreas;
if (selectedAreas.length === 0) {
this.log.info(`Initiating full cleaning...`);
await this.deviceManager.device.activateCleaning();
}
else {
this.log.info(`Initiating room cleaning...`);
await this.deviceManager.device.cleanRooms(selectedAreas);
await this.endpoint?.updateAttribute(ServiceArea.Cluster.id, 'currentArea', selectedAreas[0]);
}
break;
}
default:
this.log.warn(`Unknown mode ${data.request.newMode}`);
break;
}
});
this.endpoint.addCommandHandler('stop', async () => {
await this.deviceManager.device.deactivateCleaning();
});
this.endpoint.addCommandHandler('pause', async () => {
await this.deviceManager.device.pause();
});
this.endpoint.addCommandHandler('resume', async () => {
const selectedAreas = this.selectedAreas;
if (selectedAreas.length > 0) {
await this.deviceManager.device.resumeCleanRooms(selectedAreas);
}
else {
await this.deviceManager.device.activateCleaning();
}
});
this.endpoint.addCommandHandler('goHome', async () => {
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.SeekingCharger);
await this.deviceManager.device.activateCharging();
});
this.endpoint.addCommandHandler('identify', async () => {
await this.deviceManager.device.find();
});
this.endpoint.addCommandHandler('selectAreas', async (data) => {
this.log.debug(`Select areas command received: ${data.request.newAreas}`);
let selectedAreas = data.request.newAreas;
if (data.attributes.supportedAreas?.length === selectedAreas.length) {
selectedAreas = [];
}
await this.endpoint?.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas);
});
return this.endpoint;
}
async postRegister() {
this.deviceManager.stateChanged$
.pipe(mergeMap(async ({ key, value }) => {
this.log.debug(`Device state changed: ${key} = ${value}`);
if (key in this.stateChangedHandlers) {
await this.stateChangedHandlers[key](value);
}
}), takeUntil(this.stop$))
.subscribe();
await this.endpoint?.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null);
if (this.serviceAreas.length === 0) {
await this.endpoint?.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null);
await this.endpoint?.updateAttribute(ServiceArea.Cluster.id, 'supportedAreas', []);
}
}
stop() {
this.deviceManager.stop();
this.stop$.next();
this.stop$.complete();
}
stateChangedHandlers = {
batteryLevel: async (level) => {
this.log.debug(`Battery level: ${level}`);
await this.endpoint?.updateAttribute(PowerSource.Cluster.id, 'batPercentRemaining', level * 2);
await this.endpoint?.updateAttribute(PowerSource.Cluster.id, 'batChargeLevel', getBatteryChargeLevel(level));
},
charging: async (charging) => {
const isCharging = charging === true;
const isChargingAndFull = isCharging && this.deviceManager.property('batteryLevel') === 100;
await this.endpoint?.updateAttribute(PowerSource.Cluster.id, 'batChargeState', isChargingAndFull ? PowerSource.BatChargeState.IsAtFullCharge : isCharging ? PowerSource.BatChargeState.IsCharging : PowerSource.BatChargeState.IsNotCharging);
if (isChargingAndFull) {
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Docked);
}
else if (isCharging) {
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Charging);
}
},
cleaning: async (cleaning) => {
if (this.deviceManager.property('state') === 'error' || this.deviceManager.property('state') === 'paused') {
return;
}
await this.endpoint?.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', cleaning === false ? SUPPORTED_MODES[0].mode : SUPPORTED_MODES[1].mode);
if (cleaning) {
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Running);
}
},
cleaningMode: async (cleaningMode) => {
if (this.deviceManager.property('state') === 'paused') {
await this.stateChangedHandlers.cleaning(false);
}
else {
await this.stateChangedHandlers.cleaning(['cleaning', 'zone-cleaning', 'spot-cleaning', 'room-cleaning', 'manual-cleaning'].includes(cleaningMode));
}
},
in_returning: async (inReturning) => {
if (inReturning) {
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.SeekingCharger);
}
},
fanSpeed: async (miLevel) => {
const currentMopLevel = this.deviceManager.property('water_box_mode');
const cleanMode = this.supportedCleanModes.find(({ miLevels }) => miLevels.vacuum === miLevel && miLevels.mop === currentMopLevel);
if (cleanMode) {
await this.endpoint?.updateAttribute(RvcCleanMode.Cluster.id, 'currentMode', cleanMode.mode);
}
},
water_box_mode: async (miLevel) => {
const currentVacuumLevel = this.deviceManager.property('fanSpeed');
const cleanMode = this.supportedCleanModes.find(({ miLevels }) => miLevels.mop === miLevel && miLevels.vacuum === currentVacuumLevel);
if (cleanMode) {
await this.endpoint?.updateAttribute(RvcCleanMode.Cluster.id, 'currentMode', cleanMode.mode);
}
},
state: async (state) => {
await this.stateChangedHandlers.charging(state === 'charging');
switch (state) {
case 'charging':
break;
case 'paused':
await this.endpoint?.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', SUPPORTED_MODES[0].mode);
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Paused);
break;
case 'cleaning':
case 'spot-cleaning':
case 'room-cleaning':
case 'zone-cleaning':
case 'sweeping':
case 'mopping':
case 'sweeping-and-mopping':
await this.endpoint?.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', SUPPORTED_MODES[1].mode);
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Running);
break;
case 'returning':
case 'docking':
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.SeekingCharger);
break;
case 'error':
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Error);
break;
case 'fully-charged':
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Docked);
break;
case 'charging-error':
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Error);
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', RvcOperationalState.ErrorState.FailedToFindChargingDock);
break;
case 'initializing':
case 'idle':
case 'sleeping':
await this.endpoint?.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', SUPPORTED_MODES[0].mode);
await this.endpoint?.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Stopped);
break;
default:
this.log.warn(`Unknown state: ${state}`);
break;
}
},
};
async getServiceAreas() {
const roomMapping = await this.deviceManager.device.getRoomMap();
if (roomMapping.length > 0) {
this.log.info(`Room mapping found: ${JSON.stringify(roomMapping)}`);
this.log.info(`Creating service areas from room mapping...`);
return roomMapping.map(([roomId, roomName], index) => ({
areaId: parseInt(roomId),
mapId: null,
areaInfo: {
locationInfo: {
locationName: this.config.roomNames?.[index] || `${roomName}`,
floorNumber: null,
areaType: null,
},
landmarkInfo: null,
},
}));
}
const timers = await this.deviceManager.device.getTimer();
if (timers.length > 0) {
const timer = timers.find(([id, status, definition]) => {
if (['off', 'disabled'].includes(status)) {
const [cronExpression, action] = definition;
if (cronExpression.startsWith('0 0 * *')) {
const [, params] = action;
if (params.segments) {
this.log.debug(`Potential timer found with ID ${id}: ${JSON.stringify(action)}}`);
return true;
}
}
}
return false;
});
if (timer) {
const segments = timer[2][1][1].segments.split(',');
return segments.map((roomId, index) => ({
areaId: parseInt(roomId),
mapId: null,
areaInfo: {
locationInfo: {
locationName: this.config.roomNames?.[index] || `Room ${roomId}`,
floorNumber: null,
areaType: null,
},
landmarkInfo: null,
},
}));
}
}
return [];
}
get selectedAreas() {
const selectedAreas = this.endpoint?.getAttribute(ServiceArea.Cluster.id, 'selectedAreas') ?? [];
if (selectedAreas.length === this.serviceAreas.length) {
return [];
}
return selectedAreas;
}
get supportedCleanModes() {
const [vacuumSpeedOff, ...vacuumSpeedModes] = this.modelSpeeds.speed;
const [mopSpeedOff, ...mopSpeedModes] = this.modelSpeeds.waterspeed ?? [];
let mode = 1;
const supportedCleanModes = vacuumSpeedModes.map(({ name, miLevel, label }) => ({
label: `${name} Vacuum`,
mode: mode++,
modeTags: [
...(label === RvcCleanMode.ModeTag.Mop ? [] : [{ value: RvcCleanMode.ModeTag.Vacuum }]),
{ value: label },
],
miLevels: {
vacuum: miLevel,
mop: mopSpeedOff?.miLevel,
},
}));
mopSpeedModes.forEach(({ name, miLevel, label }) => {
supportedCleanModes.push({
label: `${name} Mop`,
mode: mode++,
modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: label }],
miLevels: {
vacuum: vacuumSpeedOff.miLevel,
mop: miLevel,
},
});
});
return supportedCleanModes;
}
}
function getBatteryChargeLevel(batteryLevel) {
return batteryLevel < 10 ? PowerSource.BatChargeLevel.Critical : batteryLevel < 20 ? PowerSource.BatChargeLevel.Warning : PowerSource.BatChargeLevel.Ok;
}