matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
902 lines • 59.1 kB
JavaScript
import { PowerSource, RvcCleanMode, RvcOperationalState, RvcRunMode, ServiceArea } from 'matterbridge/matter/clusters';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CleanModeSetting } from '../behaviors/roborock.vacuum/core/CleanModeSetting.js';
import { CleanSequenceType } from '../behaviors/roborock.vacuum/enums/CleanSequenceType.js';
import { RoomIndexMap } from '../core/application/models/RoomIndexMap.js';
import * as initialDataIndex from '../initialData/index.js';
import * as dockingStationStatus from '../model/DockStationStatus.js';
import { PlatformRunner } from '../platformRunner.js';
import { DockErrorCode, OperationStatusCode, VacuumErrorCode } from '../roborockCommunication/enums/index.js';
import { BatteryMessage, DeviceModel, } from '../roborockCommunication/models/index.js';
import * as handleHomeDataMessage from '../runtimes/handleHomeDataMessage.js';
import * as handleLocalMessage from '../runtimes/handleLocalMessage.js';
import * as runtimeHelper from '../share/runtimeHelper.js';
import { NotifyMessageTypes } from '../types/notifyMessageTypes.js';
import { asPartial, asType, createMockConfigManager, createMockDeviceRegistry, createMockLogger, createMockRoborockService, } from './testUtils.js';
vi.mock('../initialData/index.js', () => ({
getOperationalErrorState: vi.fn().mockReturnValue(2),
getBatteryStatus: vi.fn((level) => (level > 80 ? 0 : level > 20 ? 1 : 2)),
getBatteryState: vi.fn((_status, _level) => 1),
}));
vi.mock('../share/stateResolver.js', () => ({
resolveDeviceState: vi.fn((message) => {
const { status } = message;
if (status === OperationStatusCode.Cleaning) {
return { runMode: 16385, operationalState: 1 }; // Cleaning (ModeTag=16385) + Running
}
if (status === OperationStatusCode.Mapping) {
return { runMode: 16386, operationalState: 1 }; // Mapping (ModeTag=16386) + Running
}
if (status === OperationStatusCode.Idle) {
return { runMode: 16384, operationalState: 66 }; // Idle (ModeTag=16384) + Docked
}
if (status === OperationStatusCode.Unknown) {
return { runMode: 16384, operationalState: 66 }; // Idle + Docked
}
return { runMode: 16384, operationalState: 66 }; // Default: Idle + Docked
}),
}));
vi.mock('../runtimes/handleHomeDataMessage.js', () => ({
updateFromHomeData: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../runtimes/handleLocalMessage.js', () => ({
triggerDssError: vi.fn().mockResolvedValue(false),
}));
vi.mock('../model/DockStationStatus.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
hasDockingStationError: vi.fn().mockReturnValue(false),
};
});
vi.mock('../share/runtimeHelper.js', () => ({
getCleanModeResolver: vi.fn(() => ({
resolve: vi.fn().mockReturnValue(1),
})),
}));
vi.mock('../initialData/getSupportedRunModes.js', () => ({
getRunningMode: vi.fn().mockReturnValue(1),
}));
describe('PlatformRunner.requestHomeData', () => {
let platform;
let runner;
it('should return early if no robots exist', async () => {
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map()),
rrHomeId: 12345,
roborockService: createMockRoborockService({ getHomeDataForUpdating: vi.fn() }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
await runner.requestHomeData();
expect(platform.roborockService?.getHomeDataForUpdating).not.toHaveBeenCalled();
});
it('should return early if rrHomeId is not set (undefined)', async () => {
const placeholderRobot = asPartial({
serialNumber: '123',
device: asPartial({ specs: asPartial({}) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['123', placeholderRobot]])),
rrHomeId: undefined,
roborockService: createMockRoborockService({ getHomeDataForUpdating: vi.fn() }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
await runner.requestHomeData();
expect(platform.roborockService?.getHomeDataForUpdating).not.toHaveBeenCalled();
});
it('should return early if rrHomeId is falsy (empty string)', async () => {
const placeholderRobot = asPartial({
serialNumber: '123',
device: asPartial({ specs: asPartial({}) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['123', placeholderRobot]])),
rrHomeId: undefined,
roborockService: createMockRoborockService({ getHomeDataForUpdating: vi.fn() }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
await runner.requestHomeData();
expect(platform.roborockService?.getHomeDataForUpdating).not.toHaveBeenCalled();
});
it('should return early if roborockService is undefined', async () => {
const placeholderRobot = asPartial({
serialNumber: '123',
device: asPartial({ specs: asPartial({}) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['123', placeholderRobot]])),
rrHomeId: 12345,
roborockService: undefined,
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
await runner.requestHomeData();
expect(platform.roborockService).toBeUndefined();
});
it('should return early if getHomeDataForUpdating returns undefined', async () => {
const getHomeDataMock = vi.fn().mockResolvedValue(undefined);
const placeholderRobot = asPartial({
serialNumber: '123',
device: asPartial({ specs: asPartial({}) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['123', placeholderRobot]])),
rrHomeId: 12345,
roborockService: createMockRoborockService({ getHomeDataForUpdating: getHomeDataMock }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
const updateRobotWithPayloadSpy = vi.spyOn(runner, 'updateRobotWithPayload');
await runner.requestHomeData();
expect(getHomeDataMock).toHaveBeenCalledWith(12345);
expect(updateRobotWithPayloadSpy).not.toHaveBeenCalled();
});
it('should call updateRobotWithPayload when homeData is available', async () => {
const homeData = { devices: [], products: [] };
const getHomeDataMock = vi.fn().mockResolvedValue(homeData);
const placeholderRobot = asPartial({
serialNumber: '123',
device: asPartial({ specs: asPartial({}) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['123', placeholderRobot]])),
rrHomeId: 12345,
roborockService: createMockRoborockService({ getHomeDataForUpdating: getHomeDataMock }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
const updateRobotWithPayloadSpy = vi
.spyOn(runner, 'updateRobotWithPayload')
.mockImplementation(() => Promise.resolve());
await runner.requestHomeData();
expect(getHomeDataMock).toHaveBeenCalledWith(12345);
expect(updateRobotWithPayloadSpy).toHaveBeenCalledWith({ type: NotifyMessageTypes.HomeData, data: homeData });
});
it('should skip requestHomeData when all devices have real-time connection', async () => {
const getHomeDataMock = vi.fn().mockResolvedValue({ devices: [] });
const placeholderRobot = asPartial({
serialNumber: '123',
device: asPartial({ duid: '123', specs: asPartial({ hasRealTimeConnection: true }) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['123', placeholderRobot]])),
rrHomeId: 12345,
roborockService: createMockRoborockService({ getHomeDataForUpdating: getHomeDataMock }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
await runner.requestHomeData();
expect(getHomeDataMock).not.toHaveBeenCalled();
});
it('should proceed with requestHomeData when not all devices have real-time connection', async () => {
const homeData = { devices: [], products: [] };
const getHomeDataMock = vi.fn().mockResolvedValue(homeData);
const robot1 = asPartial({
serialNumber: '123',
device: asPartial({ duid: '123', specs: asPartial({ hasRealTimeConnection: true }) }),
updateAttribute: vi.fn(),
});
const robot2 = asPartial({
serialNumber: '456',
device: asPartial({ duid: '456', specs: asPartial({ hasRealTimeConnection: false }) }),
updateAttribute: vi.fn(),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([
['123', robot1],
['456', robot2],
])),
rrHomeId: 12345,
roborockService: createMockRoborockService({ getHomeDataForUpdating: getHomeDataMock }),
log: createMockLogger(),
});
runner = new PlatformRunner(platform);
vi.spyOn(runner, 'updateRobotWithPayload').mockImplementation(() => Promise.resolve());
await runner.requestHomeData();
expect(getHomeDataMock).toHaveBeenCalledWith(12345);
});
});
describe('PlatformRunner.updateRobotWithPayload', () => {
let platform;
let runner;
let robot;
let mapInfo;
const mockLogger = createMockLogger();
beforeEach(() => {
mapInfo = asPartial({
getActiveMapId: vi.fn().mockReturnValue(1),
});
robot = asPartial({
serialNumber: 'test-duid',
device: asPartial({
duid: 'test-duid',
specs: asPartial({ model: DeviceModel.S7 }),
}),
updateAttribute: vi.fn().mockResolvedValue(true),
getAttribute: vi.fn().mockReturnValue(RvcOperationalState.OperationalState.Docked),
setAttribute: vi.fn(),
cleanModeSetting: new CleanModeSetting(1, 1, 1, 1, CleanSequenceType.Persist),
dockStationStatus: asPartial({}),
homeInFo: asPartial({ activeMapId: 0, mapInfo }),
});
platform = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robot]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeVacuumErrorStatus: true }),
});
runner = new PlatformRunner(platform);
runner.activateHandlerFunctions();
vi.clearAllMocks();
});
it('should log warning for unhandled message type', async () => {
const payload = { type: 'UnknownType', data: {} };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.warn).toHaveBeenCalledWith('No handler registered for message type: UnknownType');
});
it('should handle ErrorOccurred message', async () => {
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.LidarBlocked,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.warn).toHaveBeenCalledWith('Vacuum error detected: NavigationSensorObscured');
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Error, mockLogger);
});
it('should not update operational state when getOperationalErrorState returns undefined', async () => {
vi.mocked(initialDataIndex.getOperationalErrorState).mockReturnValueOnce(undefined);
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.LidarBlocked,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.error).not.toHaveBeenCalled();
expect(robot.updateAttribute).toHaveBeenCalled();
});
it('should handle ErrorOccurred message with robot not found', async () => {
const errorMessage = {
duid: 'unknown-duid',
vacuumErrorCode: VacuumErrorCode.LidarBlocked,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.error).toHaveBeenCalledWith('Robot with DUID unknown-duid not found');
});
it('should handle BatteryUpdate message with battery level only', async () => {
const batteryMessage = new BatteryMessage('test-duid', 85, undefined, undefined);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(PowerSource.Cluster.id, 'batPercentRemaining', 170, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(PowerSource.Cluster.id, 'batChargeLevel', 0, mockLogger);
});
it('should handle BatteryUpdate message with battery level and device status', async () => {
const batteryMessage = new BatteryMessage('test-duid', 50, 1, OperationStatusCode.Charging);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(PowerSource.Cluster.id, 'batPercentRemaining', 100, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(PowerSource.Cluster.id, 'batChargeLevel', 1, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(PowerSource.Cluster.id, 'batChargeState', PowerSource.BatChargeState.IsCharging, mockLogger);
});
it('should not update battery charge state when device status is missing', async () => {
const batteryMessage = new BatteryMessage('test-duid', 50, 1, undefined);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledTimes(2);
expect(robot.updateAttribute).not.toHaveBeenCalledWith(PowerSource.Cluster.id, 'batChargeState', expect.anything(), mockLogger);
});
it('should handle DeviceStatus message', async () => {
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Cleaning,
inCleaning: true,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: false,
inWarmup: false,
};
const payload = { type: NotifyMessageTypes.DeviceStatus, data: statusMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcRunMode.Cluster.id, 'currentMode', 1, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', 1, mockLogger);
});
it('should handle Unknown status with default state', async () => {
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Unknown,
inCleaning: false,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: false,
inWarmup: false,
};
const payload = { type: NotifyMessageTypes.DeviceStatus, data: statusMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcRunMode.Cluster.id, 'currentMode', 1, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', 66, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledTimes(2);
});
it('should trigger dock station error when docking station has error', async () => {
const mockDockStatus = Object.assign(asPartial({ cleanFluidStatus: 1 }), {
hasError: vi.fn().mockReturnValue(true),
});
const robotWithErrorStatus = asPartial({
serialNumber: 'test-duid',
device: asPartial({
duid: 'test-duid',
specs: asPartial({ model: DeviceModel.S7 }),
}),
updateAttribute: vi.fn(),
cleanModeSetting: new CleanModeSetting(1, 1, 1, 1, CleanSequenceType.Persist),
dockStationStatus: mockDockStatus,
});
mockDockStatus.hasError = vi.fn().mockReturnValue(true);
const platformWithDockStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robotWithErrorStatus]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeDockStationStatus: true, includeVacuumErrorStatus: true }),
});
const runnerWithDockStatus = new PlatformRunner(platformWithDockStatus);
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Idle,
inCleaning: false,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: false,
inWarmup: false,
};
const payload = { type: NotifyMessageTypes.DeviceStatus, data: statusMessage };
runnerWithDockStatus.activateHandlerFunctions();
await runnerWithDockStatus.updateRobotWithPayload(payload);
expect(mockDockStatus.hasError).toHaveBeenCalled();
expect(handleLocalMessage.triggerDssError).toHaveBeenCalledWith(robotWithErrorStatus, platformWithDockStatus);
expect(robotWithErrorStatus.updateAttribute).not.toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', expect.anything(), mockLogger);
});
it('should not check dock station error when includeDockStationStatus is false', async () => {
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Idle,
inCleaning: false,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: false,
inWarmup: false,
};
const payload = { type: NotifyMessageTypes.DeviceStatus, data: statusMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcRunMode.Cluster.id, 'currentMode', 1, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', 66, mockLogger);
});
it('should handle CleanModeUpdate message with full settings', async () => {
const cleanModeMessage = {
duid: 'test-duid',
suctionPower: 2,
waterFlow: 3,
distance_off: 1,
mopRoute: 1,
seq_type: 0,
};
const payload = { type: NotifyMessageTypes.CleanModeUpdate, data: cleanModeMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcCleanMode.Cluster.id, 'currentMode', 1, mockLogger);
expect(robot.cleanModeSetting).toBeInstanceOf(CleanModeSetting);
});
it('should not update clean mode when settings are incomplete', async () => {
const cleanModeMessage = {
duid: 'test-duid',
suctionPower: 2,
waterFlow: 3,
distance_off: 1,
mopRoute: undefined,
seq_type: 0,
};
const payload = { type: NotifyMessageTypes.CleanModeUpdate, data: cleanModeMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should not update clean mode when resolver returns undefined', async () => {
vi.mocked(runtimeHelper.getCleanModeResolver).mockReturnValueOnce(asPartial({
resolve: vi.fn().mockReturnValue(undefined),
}));
const cleanModeMessage = {
duid: 'test-duid',
suctionPower: 2,
waterFlow: 3,
distance_off: 1,
mopRoute: 1,
seq_type: 0,
};
const payload = { type: NotifyMessageTypes.CleanModeUpdate, data: cleanModeMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should handle ServiceAreaUpdate message when state is Idle', async () => {
const selectedAreas = [1, 2, 3];
platform.roborockService = createMockRoborockService({
getSelectedAreas: vi.fn().mockReturnValue(selectedAreas),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Idle,
cleaningInfo: undefined,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, mockLogger);
});
it('should handle ServiceAreaUpdate when state is Cleaning without cleaningInfo and clean_area is 0 (Traveling to room)', async () => {
const selectedAreas = [1, 2];
platform.roborockService = createMockRoborockService({ getSelectedAreas: vi.fn().mockReturnValue(selectedAreas) });
vi.mocked(robot.getAttribute).mockReturnValue(undefined);
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo: undefined,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.notice).toHaveBeenCalledWith('Vacuum is cleaning with no cleaning_info');
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'selectedAreas', selectedAreas, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'currentArea', null, mockLogger);
});
it('should handle ServiceAreaUpdate when state is Cleaning without cleaningInfo and multiple rooms (Preparing)', async () => {
platform.roborockService = createMockRoborockService({ getSelectedAreas: vi.fn().mockReturnValue([1, 2]) });
vi.mocked(robot.getAttribute).mockReturnValue(undefined);
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo: undefined,
cleaningProcess: { clean_area: 100, clean_time: 60 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.notice).toHaveBeenCalledWith('Vacuum is cleaning with no cleaning_info');
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'selectedAreas', [], mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'currentArea', null, mockLogger);
});
it('should handle ServiceAreaUpdate when cleaningInfo is missing for non-cleaning state', async () => {
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Paused,
cleaningInfo: undefined,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.notice).toHaveBeenCalledWith('Vacuum is cleaning with no cleaning_info');
expect(robot.updateAttribute).toHaveBeenCalled();
});
it('should handle ServiceAreaUpdate with cleaningInfo and valid segment_id', async () => {
robot.homeInFo.activeMapId = 1;
const cleaningInfo = asPartial({ segment_id: 4, target_segment_id: undefined });
const mappedArea = { areaId: 4, matterAreaId: 4, mapId: 1 };
const areaInfo = new Map([[1, { roomId: 4, mapId: 1, roomName: 'roomName-1' }]]);
const roomInfo = new Map([['4-1', { areaId: 1, mapId: 1, roomName: 'roomName-1' }]]);
const indexMap = new RoomIndexMap(areaInfo, roomInfo);
platform.roborockService = createMockRoborockService({
getSupportedAreas: vi.fn().mockReturnValue([mappedArea]),
getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
runner.updateRobotWithPayload(payload);
await Promise.resolve();
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'currentArea', 1, mockLogger);
});
it('should handle ServiceAreaUpdate with target_segment_id when segment_id is invalid', async () => {
robot.homeInFo.activeMapId = 1;
const cleaningInfo = asPartial({ segment_id: -1, target_segment_id: 5 });
const mappedArea = { areaId: 5, matterAreaId: 5, mapId: 1 };
const areaInfo = new Map([[1, { roomId: 5, mapId: 1, roomName: 'roomName-1' }]]);
const roomInfo = new Map([['5-1', { areaId: 1, mapId: 1, roomName: 'roomName-1' }]]);
const indexMap = new RoomIndexMap(areaInfo, roomInfo);
platform.roborockService = createMockRoborockService({
getSupportedAreas: vi.fn().mockReturnValue([mappedArea]),
getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
runner.updateRobotWithPayload(payload);
await Promise.resolve();
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'currentArea', 1, mockLogger);
});
it('should set currentArea to null when segment_id is INVALID_SEGMENT_ID and mapped area exists', async () => {
const cleaningInfo = asPartial({ segment_id: -1, target_segment_id: -1 });
const mappedArea = { areaId: -1, matterAreaId: -1, mapId: 1 };
const areaInfo = new Map([[1, { roomId: 10, mapId: 1, roomName: 'roomName-1' }]]);
const roomInfo = new Map([['10-1', { areaId: 1, mapId: 1, roomName: 'roomName-1' }]]);
const indexMap = new RoomIndexMap(areaInfo, roomInfo);
platform.roborockService = createMockRoborockService({
getSupportedAreas: vi.fn().mockReturnValue([mappedArea]),
getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
runner.updateRobotWithPayload(payload);
await Promise.resolve();
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'currentArea', null, mockLogger);
});
it('should skip area mapping when no mapped area found', async () => {
const cleaningInfo = asPartial({ segment_id: 10, target_segment_id: undefined });
const areaInfo = new Map([[1, { roomId: 11, mapId: 1, roomName: 'roomName-1' }]]);
const roomInfo = new Map([['11-1', { areaId: 1, mapId: 1, roomName: 'roomName-1' }]]);
const indexMap = new RoomIndexMap(areaInfo, roomInfo);
platform.roborockService = createMockRoborockService({
getSupportedAreas: vi.fn().mockReturnValue([]),
getSupportedAreasIndexMap: vi.fn().mockReturnValue(indexMap),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
runner.updateRobotWithPayload(payload);
await Promise.resolve();
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('No mapped area found'));
// expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should return early when roborockService is undefined for ServiceAreaUpdate', async () => {
platform.roborockService = undefined;
const cleaningInfo = asPartial({ segment_id: 4, target_segment_id: undefined });
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should handle HomeData message', async () => {
const homeData = { devices: [], products: [] };
const payload = { type: NotifyMessageTypes.HomeData, data: asPartial(homeData) };
await runner.updateRobotWithPayload(payload);
expect(handleHomeDataMessage.updateFromHomeData).toHaveBeenCalledWith(homeData, platform);
});
it('should not process messages when handlers are not activated', async () => {
const newRunner = new PlatformRunner(platform);
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.LidarBlocked,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await newRunner.updateRobotWithPayload(payload);
expect(mockLogger.warn).not.toHaveBeenCalled();
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should clear error when vacuum is running without errors', async () => {
vi.mocked(robot.getAttribute).mockReturnValue(RvcOperationalState.OperationalState.Running);
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.None,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.debug).toHaveBeenCalledWith('Vacuum running without errors, clearing error state.');
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, mockLogger);
});
it('should process dock station errors when includeDockStationStatus is enabled', async () => {
const mockDockStatus = Object.assign(asPartial({}), {
hasError: vi.fn().mockReturnValue(true),
getMatterOperationalError: vi.fn().mockReturnValue(RvcOperationalState.ErrorState.FailedToFindChargingDock),
});
const platformWithDockStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robot]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeDockStationStatus: true, includeVacuumErrorStatus: true }),
});
const runnerWithDockStatus = new PlatformRunner(platformWithDockStatus);
runnerWithDockStatus.activateHandlerFunctions();
vi.spyOn(dockingStationStatus.DockStationStatus, 'parseDockStationStatus').mockReturnValue(mockDockStatus);
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.None,
dockErrorCode: DockErrorCode.None,
dockStationStatus: 168,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runnerWithDockStatus.updateRobotWithPayload(payload);
expect(mockDockStatus.hasError).toHaveBeenCalled();
expect(mockDockStatus.getMatterOperationalError).toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith('Docking station error detected: FailedToFindChargingDock');
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Error, mockLogger);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.FailedToFindChargingDock }, mockLogger);
});
it('should clear errors when dock station has no errors', async () => {
const mockDockStatus = Object.assign(asPartial({}), {
hasError: vi.fn().mockReturnValue(false),
});
const platformWithDockStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robot]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeDockStationStatus: true, includeVacuumErrorStatus: true }),
});
const runnerWithDockStatus = new PlatformRunner(platformWithDockStatus);
runnerWithDockStatus.activateHandlerFunctions();
vi.spyOn(dockingStationStatus.DockStationStatus, 'parseDockStationStatus').mockReturnValue(mockDockStatus);
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.None,
dockErrorCode: DockErrorCode.None,
dockStationStatus: 0,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runnerWithDockStatus.updateRobotWithPayload(payload);
expect(mockLogger.debug).toHaveBeenCalledWith('No docking station errors detected.');
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, mockLogger);
});
it('should clear errors when no errors detected and no dock station processing', async () => {
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.None,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Handling error occurred'));
// expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, mockLogger);
});
it('should not update battery charge state when battery level is missing', async () => {
const batteryMessage = new BatteryMessage('test-duid', asType(undefined), 1, OperationStatusCode.Charging);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should handle DeviceStatusSimple message with undefined state', async () => {
const statusMessage = { duid: 'test-duid', status: OperationStatusCode.Unknown };
const payload = { type: NotifyMessageTypes.DeviceStatusSimple, data: statusMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcRunMode.Cluster.id, 'currentMode', 1, mockLogger);
});
it('should trigger dock station error in DeviceStatusSimple when dock has error', async () => {
const mockDockStatus = Object.assign(asPartial({}), {
hasError: vi.fn().mockReturnValue(true),
});
const robotWithDockError = asPartial({
serialNumber: 'test-duid',
device: asPartial({
duid: 'test-duid',
specs: asPartial({ model: DeviceModel.S7 }),
}),
updateAttribute: vi.fn(),
dockStationStatus: mockDockStatus,
});
const platformWithDockStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robotWithDockError]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeDockStationStatus: true, includeVacuumErrorStatus: true }),
});
const runnerWithDockStatus = new PlatformRunner(platformWithDockStatus);
runnerWithDockStatus.activateHandlerFunctions();
const statusMessage = { duid: 'test-duid', status: OperationStatusCode.Idle };
const payload = { type: NotifyMessageTypes.DeviceStatusSimple, data: statusMessage };
await runnerWithDockStatus.updateRobotWithPayload(payload);
expect(handleLocalMessage.triggerDssError).toHaveBeenCalledWith(robotWithDockError, platformWithDockStatus);
expect(robotWithDockError.updateAttribute).not.toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', expect.anything(), mockLogger);
});
it('should handle ServiceAreaUpdate when getSelectedAreas returns empty array', async () => {
platform.roborockService = createMockRoborockService({
getSelectedAreas: vi.fn().mockReturnValue([]),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Idle,
cleaningInfo: undefined,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(ServiceArea.Cluster.id, 'selectedAreas', [], mockLogger);
});
it('should handle ServiceAreaUpdate with cleaningInfo but getSupportedAreas returns undefined', async () => {
const cleaningInfo = asPartial({ segment_id: 4, target_segment_id: undefined });
platform.roborockService = createMockRoborockService({
getSupportedAreas: vi.fn().mockReturnValue(undefined),
getSupportedAreasIndexMap: vi.fn().mockReturnValue(undefined),
});
const serviceAreaMessage = {
duid: 'test-duid',
state: OperationStatusCode.Cleaning,
cleaningInfo,
cleaningProcess: { clean_area: 0, clean_time: 0 },
};
const payload = { type: NotifyMessageTypes.ServiceAreaUpdate, data: serviceAreaMessage };
await runner.updateRobotWithPayload(payload);
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('No room mapping found.'));
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should skip error handling when includeVacuumErrorStatus is disabled', async () => {
const platformNoErrorStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robot]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeVacuumErrorStatus: false }),
});
const runnerNoError = new PlatformRunner(platformNoErrorStatus);
runnerNoError.activateHandlerFunctions();
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.LidarBlocked,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runnerNoError.updateRobotWithPayload(payload);
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Skipping error handling: includeVacuumErrorStatus is disabled'));
expect(robot.updateAttribute).not.toHaveBeenCalled();
});
it('should handle dockErrorCode error when no dockStationStatus and dockErrorCode is not None', async () => {
const platformWithDockStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robot]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeDockStationStatus: true, includeVacuumErrorStatus: true }),
});
const runnerWithDock = new PlatformRunner(platformWithDockStatus);
runnerWithDock.activateHandlerFunctions();
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.None,
dockErrorCode: DockErrorCode.WaterEmpty,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runnerWithDock.updateRobotWithPayload(payload);
expect(mockLogger.warn).toHaveBeenCalledWith('Docking station error detected: WaterTankEmpty');
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.WaterTankEmpty }, mockLogger);
});
it('should clear errors when dockErrorCode is None', async () => {
vi.mocked(robot.getAttribute).mockReturnValue(RvcOperationalState.OperationalState.Docked);
const platformWithDockStatus = asPartial({
registry: createMockDeviceRegistry({}, new Map([['test-duid', robot]])),
log: mockLogger,
roborockService: createMockRoborockService(),
configManager: createMockConfigManager({ includeDockStationStatus: true, includeVacuumErrorStatus: true }),
});
const runnerWithDock = new PlatformRunner(platformWithDockStatus);
runnerWithDock.activateHandlerFunctions();
const errorMessage = {
duid: 'test-duid',
vacuumErrorCode: VacuumErrorCode.None,
dockErrorCode: DockErrorCode.None,
dockStationStatus: undefined,
};
const payload = { type: NotifyMessageTypes.ErrorOccurred, data: errorMessage };
await runnerWithDock.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalError', { errorStateId: RvcOperationalState.ErrorState.NoError }, mockLogger);
});
it('should transition operational state to Charging when battery is charging and robot is Docked', async () => {
vi.mocked(robot.getAttribute).mockReturnValue(RvcOperationalState.OperationalState.Docked);
vi.mocked(initialDataIndex.getBatteryState).mockReturnValue(PowerSource.BatChargeState.IsCharging);
const batteryMessage = new BatteryMessage('test-duid', 50, 1, OperationStatusCode.Charging);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Charging, mockLogger);
});
it('should transition operational state to Docked when battery is fully charged and robot is Charging', async () => {
vi.mocked(robot.getAttribute).mockReturnValue(RvcOperationalState.OperationalState.Charging);
vi.mocked(initialDataIndex.getBatteryState).mockReturnValue(PowerSource.BatChargeState.IsAtFullCharge);
const batteryMessage = new BatteryMessage('test-duid', 100, 1, OperationStatusCode.Charging);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Docked, mockLogger);
});
it('should not transition to Charging when battery is charging but robot is not Docked', async () => {
vi.mocked(robot.getAttribute).mockReturnValue(RvcOperationalState.OperationalState.Running);
vi.mocked(initialDataIndex.getBatteryState).mockReturnValue(PowerSource.BatChargeState.IsCharging);
const batteryMessage = new BatteryMessage('test-duid', 50, 1, OperationStatusCode.Charging);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).not.toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Charging, mockLogger);
});
it('should not transition to Docked when fully charged but robot is not Charging', async () => {
vi.mocked(robot.getAttribute).mockReturnValue(RvcOperationalState.OperationalState.Docked);
vi.mocked(initialDataIndex.getBatteryState).mockReturnValue(PowerSource.BatChargeState.IsAtFullCharge);
const batteryMessage = new BatteryMessage('test-duid', 100, 1, OperationStatusCode.Charging);
const payload = { type: NotifyMessageTypes.BatteryUpdate, data: batteryMessage };
await runner.updateRobotWithPayload(payload);
expect(robot.updateAttribute).not.toHaveBeenCalledWith(RvcOperationalState.Cluster.id, 'operationalState', RvcOperationalState.OperationalState.Docked, mockLogger);
});
it('should auto-start burst polling when device enters Cleaning state', async () => {
vi.useFakeTimers();
const startBurstSpy = vi.spyOn(runner.burstPolling, 'startBurstPolling');
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Cleaning,
inCleaning: true,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: false,
inWarmup: false,
};
await runner.updateRobotWithPayload({ type: NotifyMessageTypes.DeviceStatus, data: statusMessage });
expect(startBurstSpy).toHaveBeenCalledWith('test-duid');
vi.useRealTimers();
});
it('should auto-start burst polling when device enters Mapping state', async () => {
vi.useFakeTimers();
const startBurstSpy = vi.spyOn(runner.burstPolling, 'startBurstPolling');
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Mapping,
inCleaning: false,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: true,
inWarmup: false,
};
await runner.updateRobotWithPayload({ type: NotifyMessageTypes.DeviceStatus, data: statusMessage });
expect(startBurstSpy).toHaveBeenCalledWith('test-duid');
vi.useRealTimers();
});
it('should not auto-start burst polling when device is in Idle state', async () => {
const startBurstSpy = vi.spyOn(runner.burstPolling, 'startBurstPolling');
const statusMessage = {
duid: 'test-duid',
status: OperationStatusCode.Charging,
inCleaning: false,
inReturning: false,
inFreshState: false,
isLocating: false,
isExploring: false,
inWarmup: false,
};
await runner.updateRobotWithPayload({ type: NotifyMessageTypes.DeviceStatus, data: statusMessage });
expect(startBurstSpy).not.toHaveBeenCalled();
});
it('should not start a second burst polling if already active for the device', async () => {
vi.useFakeTimers();
const startBurstSpy = vi.spyOn(runner.burstPolling, 'startBurstPolling');
runner.b