matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
378 lines • 22.2 kB
JavaScript
import { debugStringify } from 'matterbridge/logger';
import { PowerSource, RvcCleanMode, RvcOperationalState, RvcRunMode, ServiceArea } from 'matterbridge/matter/clusters';
import { CleanModeSetting } from './behaviors/roborock.vacuum/core/CleanModeSetting.js';
import { CleanSequenceType } from './behaviors/roborock.vacuum/enums/CleanSequenceType.js';
import { INVALID_SEGMENT_ID } from './constants/index.js';
import { getRunningMode } from './initialData/getSupportedRunModes.js';
import { getBatteryState, getBatteryStatus } from './initialData/index.js';
import { DockStationStatus } from './model/DockStationStatus.js';
import { VacuumStatus } from './model/VacuumStatus.js';
import { BurstPollingManager } from './platform/burstPollingManager.js';
import { DockErrorCode, OperationStatusCode } from './roborockCommunication/enums/index.js';
import { updateFromHomeData } from './runtimes/handleHomeDataMessage.js';
import { triggerDssError } from './runtimes/handleLocalMessage.js';
import { state_to_matter_operational_status, state_to_matter_state } from './share/function.js';
import { getCleanModeName, getOperationalErrorName, getOperationalStateName, getRunModeName, getRunModeNameV2, } from './share/matterStateNames.js';
import { getCleanModeResolver } from './share/runtimeHelper.js';
import { resolveDeviceState } from './share/stateResolver.js';
import { NotifyMessageTypes } from './types/notifyMessageTypes.js';
export class PlatformRunner {
platform;
activateHandlers = false;
static CLEANING_STATES = new Set([
OperationStatusCode.RoomClean,
OperationStatusCode.ZoneClean,
OperationStatusCode.SpotCleaning,
OperationStatusCode.RoomMopping,
OperationStatusCode.ZoneMopping,
OperationStatusCode.RoomCleanMopCleaning,
OperationStatusCode.RoomCleanMopMopping,
OperationStatusCode.ZoneCleanMopCleaning,
OperationStatusCode.ZoneCleanMopMopping,
OperationStatusCode.Paused,
OperationStatusCode.Cleaning,
OperationStatusCode.Mapping,
OperationStatusCode.CleanMopCleaning,
OperationStatusCode.CleanMopMopping,
]);
burstPolling;
constructor(platform) {
this.platform = platform;
this.burstPolling = new BurstPollingManager(platform);
}
activateHandlerFunctions() {
this.activateHandlers = true;
}
/**
* Request and process home data update from Roborock service.
* Fetches latest home data including device states and triggers robot state updates.
* Returns early if no robots configured, no home ID set, or service unavailable.
*/
async requestHomeData() {
const platform = this.platform;
if (platform.registry.robotsMap.size === 0 || !platform.rrHomeId)
return;
if (platform.roborockService === undefined)
return;
const robots = [...platform.registry.robotsMap.values()];
const allDevicesHaveRealTimeConnection = robots.every((x) => x.device.specs.hasRealTimeConnection);
if (allDevicesHaveRealTimeConnection)
return;
const homeData = await platform.roborockService.getHomeDataForUpdating(platform.rrHomeId);
if (homeData === undefined)
return;
await this.updateRobotWithPayload({ type: NotifyMessageTypes.HomeData, data: homeData });
}
/**
* Update robot state based on message payload.
* Routes to appropriate handler using type-safe discriminated unions.
*/
async updateRobotWithPayload(payload) {
if (!this.activateHandlers)
return;
const { type } = payload;
switch (type) {
case NotifyMessageTypes.ErrorOccurred:
await this.executeWithRobot(payload.data.duid, payload.data, this.handleErrorOccurred.bind(this));
break;
case NotifyMessageTypes.BatteryUpdate:
await this.executeWithRobot(payload.data.duid, payload.data, this.handleBatteryUpdate.bind(this));
break;
case NotifyMessageTypes.DeviceStatus:
await this.executeWithRobot(payload.data.duid, payload.data, this.handleDeviceStatusUpdate.bind(this));
break;
case NotifyMessageTypes.DeviceStatusSimple:
await this.executeWithRobot(payload.data.duid, payload.data, this.handleDeviceStatusSimpleUpdate.bind(this));
break;
case NotifyMessageTypes.CleanModeUpdate:
await this.executeWithRobot(payload.data.duid, payload.data, this.handleCleanModeUpdate.bind(this));
break;
case NotifyMessageTypes.ServiceAreaUpdate:
await this.executeWithRobot(payload.data.duid, payload.data, this.handleServiceAreaUpdate.bind(this));
break;
case NotifyMessageTypes.HomeData:
await updateFromHomeData(payload.data, this.platform);
break;
default:
this.platform.log.warn(`No handler registered for message type: ${type}`);
}
}
/**
* Template method: Execute handler with robot instance.
* Handles robot lookup, error logging, and passes data to handler.
*/
async executeWithRobot(duid, data, handler) {
const robot = this.getRobotOrLogError(duid);
if (!robot)
return;
await handler(robot, data);
}
/**
* Get robot by DUID or log error if not found.
*/
getRobotOrLogError(duid) {
const robot = this.platform.registry.getRobot(duid);
if (!robot) {
this.platform.log.error(`Robot with DUID ${duid} not found`);
}
return robot;
}
/**
* Handle error occurred messages and update robot operational state.
* Processes vacuum and docking station errors, updating Matter attributes accordingly.
* Prioritizes vacuum errors over docking station errors.
*/
async handleErrorOccurred(robot, message) {
this.platform.log.debug(`Handling error occurred: ${debugStringify(message)}`);
if (!this.platform.configManager.includeVacuumErrorStatus) {
this.platform.log.debug(`Skipping error handling: includeVacuumErrorStatus is disabled, message: ${debugStringify(message)}`);
return;
}
this.platform.log.debug(`Handling error occurred: ${debugStringify(message)}`);
const currentOperationState = robot.getAttribute(RvcOperationalState.Cluster.id, 'operationalState', this.platform.log);
// Process vacuum errors (highest priority)
const vacuumStatus = new VacuumStatus(message.vacuumErrorCode ?? 0);
if (vacuumStatus.hasError()) {
const errorDetail = vacuumStatus.getErrorState();
this.platform.log.warn(`Vacuum error detected: ${getOperationalErrorName(errorDetail)}`);
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Error, this.platform.log);
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: errorDetail }, this.platform.log);
return;
}
// If vacuum is running with no errors, clear any previous errors and skip dock processing
if (currentOperationState === RvcOperationalState.OperationalState.Running) {
this.platform.log.debug('Vacuum running without errors, clearing error state.');
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, this.platform.log);
return;
}
if (!this.platform.configManager.includeDockStationStatus) {
return;
}
if (message.dockStationStatus !== undefined && message.dockStationStatus !== null) {
// Process dock station errors (only when vacuum not running)
const dockStatus = DockStationStatus.parseDockStationStatus(message.dockStationStatus);
robot.dockStationStatus = dockStatus;
if (dockStatus.hasError()) {
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Error, this.platform.log);
const errorDetail = dockStatus.getMatterOperationalError();
this.platform.log.warn(`Docking station error detected: ${getOperationalErrorName(errorDetail)}`);
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: errorDetail }, this.platform.log);
}
else {
this.platform.log.debug('No docking station errors detected.');
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, this.platform.log);
}
return;
}
if (message.dockErrorCode !== DockErrorCode.None) {
const dockStatus = DockStationStatus.parseDockErrorCode(message.dockErrorCode);
if (dockStatus !== RvcOperationalState.ErrorState.NoError) {
this.platform.log.warn(`Docking station error detected: ${getOperationalErrorName(dockStatus)}`);
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: dockStatus }, this.platform.log);
}
else {
this.platform.log.debug('No docking station errors detected.');
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, this.platform.log);
}
return;
}
// No errors detected and no dock station processing
this.platform.log.debug('No errors detected, clearing operational error state.');
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, this.platform.log);
}
/**
* Handle battery update messages and update robot power attributes.
*/
async handleBatteryUpdate(robot, message) {
this.platform.log.debug(`Handling battery update: ${debugStringify(message)}`);
const batteryLevel = message.percentage;
const deviceStatus = message.deviceStatus;
const batteryChargeLevel = getBatteryStatus(batteryLevel);
if (batteryLevel) {
await robot.updateAttribute(PowerSource.Cluster.id, 'batPercentRemaining', batteryLevel * 2, this.platform.log);
await robot.updateAttribute(PowerSource.Cluster.id, 'batChargeLevel', batteryChargeLevel, this.platform.log);
}
if (batteryLevel && deviceStatus) {
const batteryChargeState = getBatteryState(deviceStatus, batteryLevel);
await robot.updateAttribute(PowerSource.Cluster.id, 'batChargeState', batteryChargeState, this.platform.log);
const currentOperationState = robot.getAttribute(RvcOperationalState.Cluster.id, 'operationalState', this.platform.log);
if (batteryChargeState === PowerSource.BatChargeState.IsCharging &&
currentOperationState === RvcOperationalState.OperationalState.Docked) {
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Charging, this.platform.log);
}
if (batteryChargeState === PowerSource.BatChargeState.IsAtFullCharge &&
currentOperationState === RvcOperationalState.OperationalState.Charging) {
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Docked, this.platform.log);
}
}
}
/**
* Handle device status notify messages and update robot run mode and operational state.
* Uses state resolution matrix to determine final state based on status code and modifiers.
*/
async handleDeviceStatusUpdate(robot, message) {
this.platform.log.debug(`Handling device status update: ${debugStringify(message)}`);
// Check docking station errors before state resolution
const includeDockStationStatus = this.platform.configManager.includeDockStationStatus;
const dssHasError = includeDockStationStatus && (robot.dockStationStatus?.hasError() ?? false);
if (dssHasError) {
await triggerDssError(robot, this.platform);
return;
}
const currentRunMode = robot.getAttribute(RvcRunMode.Cluster.id, 'currentMode');
const currentOperationState = robot.getAttribute(RvcOperationalState.Cluster.id, 'operationalState');
// Resolve state using state resolution matrix
const resolvedState = resolveDeviceState(message);
this.platform.log.notice(`[${robot.device.duid}] [${robot.device.name}] Resolved state:
currentRunMode=${getRunModeNameV2(currentRunMode)}, code=${currentRunMode}
currentOperationState=${getOperationalStateName(currentOperationState)}, code=${currentOperationState}
newRunMode=${getRunModeName(resolvedState.runMode)}, code=${getRunningMode(resolvedState.runMode)}
newOperationalState=${getOperationalStateName(resolvedState.operationalState)}, code=${resolvedState.operationalState}`);
if (currentOperationState === RvcOperationalState.OperationalState.Charging &&
resolvedState.runMode === RvcRunMode.ModeTag.Idle &&
resolvedState.operationalState === RvcOperationalState.OperationalState.Docked) {
// Device is still charging; skip Docked update and let handleBatteryUpdate transition away from Charging when battery is full.
this.platform.log.debug(`Device is still charging, skipping Docked state update`);
return;
}
// Update Matter attributes
await robot.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', getRunningMode(resolvedState.runMode), this.platform.log);
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', resolvedState.operationalState, this.platform.log);
// Auto-start burst polling when the device enters an active state
const isActive = resolvedState.runMode === RvcRunMode.ModeTag.Cleaning || resolvedState.runMode === RvcRunMode.ModeTag.Mapping;
if (isActive && !this.burstPolling.has(robot.device.duid)) {
this.burstPolling.startBurstPolling(robot.device.duid);
}
}
/**
* Handle simple device status updates from home data API.
* For devices without real-time connection, uses only status code without modifier flags.
* Converts to StatusChangeMessage with undefined modifiers for state resolution.
*/
async handleDeviceStatusSimpleUpdate(robot, message) {
this.platform.log.debug(`Handling simple device status update: ${debugStringify(message)}`);
const state = state_to_matter_state(message.status);
this.platform.log.debug(`Resolved state from simple update: ${state !== undefined ? getRunModeName(state) : 'undefined'}`);
if (state !== undefined) {
await robot.updateAttribute(RvcRunMode.Cluster.id, 'currentMode', getRunningMode(state), this.platform.log);
}
const includeDockStationStatus = this.platform.configManager.includeDockStationStatus;
const operationalStateId = state_to_matter_operational_status(state);
const dssHasError = includeDockStationStatus && (robot.dockStationStatus?.hasError() ?? false);
if (dssHasError) {
await triggerDssError(robot, this.platform);
return;
}
if (operationalStateId !== undefined) {
this.platform.log.debug(`Updating operational state to: ${getOperationalStateName(operationalStateId)}`);
await robot.updateAttribute(RvcOperationalState.Cluster.id, 'operationalState', operationalStateId, this.platform.log);
}
}
/**
* Handle clean mode update messages and update robot clean mode.
* Processes clean mode settings (suction power, water flow, mop route).
*/
async handleCleanModeUpdate(robot, message) {
this.platform.log.debug(`Handling clean mode update: ${debugStringify(message)}`);
const deviceData = robot.device.specs;
// Update RvcCleanMode based on clean mode settings
const currentCleanModeSetting = new CleanModeSetting(message.suctionPower, message.waterFlow, message.distance_off, message.mopRoute, message.seq_type ?? CleanSequenceType.Persist);
if (currentCleanModeSetting.hasFullSettings) {
robot.cleanModeSetting = currentCleanModeSetting;
const forceRunAtDefault = this.platform.configManager.forceRunAtDefault;
const currentCleanModeResolver = getCleanModeResolver(deviceData.model, forceRunAtDefault);
const currentCleanMode = currentCleanModeResolver.resolve(currentCleanModeSetting);
if (currentCleanMode) {
this.platform.log.notice(`Calculated current clean mode: ${getCleanModeName(currentCleanMode)}`);
await robot.updateAttribute(RvcCleanMode.Cluster.id, 'currentMode', currentCleanMode, this.platform.log);
}
}
}
/**
* Handle service area update messages and update robot service area attributes.
* Processes service area changes (supported areas, maps, selected areas, current area).
*/
async handleServiceAreaUpdate(robot, message) {
const logger = this.platform.log;
logger.debug(`Handling service area update: ${debugStringify(message)}`);
if (message.state === OperationStatusCode.Idle) {
logger.debug('Robot is idle, updating selectedAreas from Roborock service');
const selectedAreas = this.platform.roborockService?.getSelectedAreas(robot.device.duid) ?? [];
await robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, logger);
return;
}
if (!message.cleaningInfo && PlatformRunner.CLEANING_STATES.has(message.state)) {
await this.handleCleaningWithoutInfo(robot, message);
return;
}
if (!message.cleaningInfo) {
logger.debug('No cleaning_info available, skipping service area update');
return;
}
await this.resolveAreaFromCleaningInfo(robot, message);
}
getSelectedAreas(robot, message) {
return (robot.getAttribute(ServiceArea.Cluster.id, 'selectedAreas', this.platform.log) ??
this.platform.roborockService?.getSelectedAreas(message.duid) ??
[]);
}
async handleCleaningWithoutInfo(robot, message) {
const logger = this.platform.log;
logger.notice('Vacuum is cleaning with no cleaning_info');
const selectedAreas = this.getSelectedAreas(robot, message);
if (message.cleaningProcess.clean_area === 0 || message.cleaningProcess.clean_time === 0) {
// Robot not started cleaning yet → "Traveling to room"
await robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, logger);
await robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null, logger);
return;
}
if (selectedAreas.length === 1) {
// Single room → "Cleaning (Room)"
await robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, logger);
await robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', selectedAreas[0], logger);
}
else {
// Multiple rooms, no cleaningInfo → "Preparing" (workaround)
await robot.updateAttribute(ServiceArea.Cluster.id, 'selectedAreas', [], logger);
await robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null, logger);
}
}
async resolveAreaFromCleaningInfo(robot, message) {
const logger = this.platform.log;
const { cleaningInfo } = message;
if (!cleaningInfo)
return;
const roomIndexMap = this.platform.roborockService?.getSupportedAreasIndexMap(robot.device.duid);
if (!roomIndexMap || !this.platform.roborockService) {
logger.error('No room mapping found.');
return;
}
if (this.platform.configManager.isMultipleMapEnabled) {
const roomData = await this.platform.roborockService.getRoomMap(robot.device.duid, robot.homeInFo.activeMapId);
robot.homeInFo.activeMapId = robot.homeInFo.mapInfo.getActiveMapId(roomData);
}
const source_segment_id = cleaningInfo.segment_id ?? INVALID_SEGMENT_ID;
const source_target_segment_id = cleaningInfo.target_segment_id ?? INVALID_SEGMENT_ID;
const segment_id = source_segment_id !== INVALID_SEGMENT_ID ? source_segment_id : source_target_segment_id;
const mappedArea = roomIndexMap.getAreaId(segment_id, robot.homeInFo.activeMapId);
if (!mappedArea) {
logger.debug(`No mapped area found, skipping area mapping.
source_segment_id: ${source_segment_id},
source_target_segment_id: ${source_target_segment_id},
segment_id: ${segment_id},
currentMappedAreas: ${debugStringify(roomIndexMap)}`);
await robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', null, logger);
return;
}
const supportedAreas = this.platform.roborockService.getSupportedAreas(robot.device.duid);
const activeArea = supportedAreas.find((x) => x.areaId === mappedArea);
logger.debug(`Mapped area found:
source_segment_id: ${source_segment_id},
source_target_segment_id: ${source_target_segment_id},
segment_id: ${segment_id},
currentMappedAreas: ${debugStringify(roomIndexMap)},
activeArea: ${debugStringify(activeArea)}`);
await robot.updateAttribute(ServiceArea.Cluster.id, 'currentArea', mappedArea, logger);
}
}
//# sourceMappingURL=platformRunner.js.map