UNPKG

matterbridge-xiaomi-roborock

Version:
335 lines (334 loc) 17.4 kB
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; }