matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
345 lines • 14.8 kB
JavaScript
import { debugStringify } from 'matterbridge/logger';
import { ROUTINE_MAP_ID } from '../constants/ids.js';
import { MapInfo, RoomIndexMap, RoomMap } from '../core/application/models/index.js';
import { HomeEntity } from '../core/domain/entities/Home.js';
import { DeviceError } from '../errors/index.js';
import { getSupportedAreas, toSupportedMaps, } from '../initialData/getSupportedAreas.js';
import { mergeSupportedAreasByMap } from '../initialData/mergeSupportedAreasByMap.js';
import { HomeModelMapper } from '../roborockCommunication/models/home/index.js';
/** Manages cleaning areas, rooms, maps, and scenes. */
export class AreaManagementService {
logger;
serviceRouting;
liveMapUpdates;
enableMultipleMap;
supportedAreas = new Map();
supportedMaps = new Map();
supportedRoutines = new Map();
selectedAreas = new Map();
progress = new Map();
lastActivelyCleaningState = new Map();
supportedAreaIndexMaps = new Map();
areasListeners = new Map();
refreshIntervals = new Map();
deviceRooms = new Map();
iotApi;
mapInfoCache = new Map();
v1RoomResolutionCache = new Map();
constructor(logger, serviceRouting, liveMapUpdates = false, enableMultipleMap = true) {
this.logger = logger;
this.serviceRouting = serviceRouting;
this.liveMapUpdates = liveMapUpdates;
this.enableMultipleMap = enableMultipleMap;
}
getPrimaryMapId(duid) {
return this.mapInfoCache.get(duid)?.maps[0]?.id;
}
isMultipleMapEnabled() {
return this.enableMultipleMap;
}
setIotApi(iotApi) {
this.iotApi = iotApi;
}
setDeviceRooms(duid, rooms) {
this.deviceRooms.set(duid, rooms);
}
setSelectedAreas(duid, selectedAreas) {
this.logger.debug('AreaManagementService - setSelectedAreas', debugStringify(selectedAreas));
this.selectedAreas.set(duid, selectedAreas);
}
getSelectedAreas(duid) {
return this.selectedAreas.get(duid) ?? [];
}
setProgress(duid, progress) {
this.logger.debug('AreaManagementService - setProgress', debugStringify(progress));
this.progress.set(duid, progress);
}
getProgress(duid) {
return this.progress.get(duid) ?? [];
}
/** Store the "was actively cleaning" state for a device. */
setLastActivelyCleaningState(duid, isActivelyCleaning) {
this.logger.debug('AreaManagementService - setLastActivelyCleaningState', { duid, isActivelyCleaning });
this.lastActivelyCleaningState.set(duid, isActivelyCleaning);
}
/** Retrieve the stored "was actively cleaning" state for a device. */
getLastActivelyCleaningState(duid) {
return this.lastActivelyCleaningState.get(duid) ?? false;
}
registerAreasListener(duid, callback) {
this.areasListeners.set(duid, callback);
}
setSupportedMaps(duid, maps) {
this.supportedMaps.set(duid, maps);
}
getSupportedMaps(duid) {
return this.supportedMaps.get(duid) ?? [];
}
setSupportedAreas(duid, supportedAreas) {
this.supportedAreas.set(duid, supportedAreas);
const maps = this.supportedMaps.get(duid) ?? [];
this.areasListeners.get(duid)?.(supportedAreas, maps);
}
setSupportedAreaIndexMap(duid, indexMap) {
this.supportedAreaIndexMaps.set(duid, indexMap);
}
setSupportedRoutines(duid, routineAsRooms) {
this.supportedRoutines.set(duid, routineAsRooms);
}
getSupportedAreas(duid) {
return this.supportedAreas.get(duid) ?? [];
}
getSupportedAreasIndexMap(duid) {
return this.supportedAreaIndexMaps.get(duid);
}
mergeSupportedAreasForMap(duid, mapId, incomingAreas, incomingIndexMap) {
const existingAreas = this.getSupportedAreas(duid);
const existingIndexMap = this.getSupportedAreasIndexMap(duid);
const { supportedAreas, roomIndexMap } = mergeSupportedAreasByMap(existingAreas, incomingAreas, mapId, existingIndexMap, incomingIndexMap);
this.setSupportedAreaIndexMap(duid, roomIndexMap);
this.setSupportedAreas(duid, supportedAreas);
}
getSupportedRoutines(duid) {
return this.supportedRoutines.get(duid);
}
async fetchAndApplyMapInfo(duid) {
if (!this.serviceRouting) {
throw new DeviceError('Service routing not initialized', duid);
}
const mapInfo = await this.serviceRouting.getMapInfo(duid);
this.mapInfoCache.set(duid, mapInfo);
if (mapInfo.hasRooms) {
const rooms = this.deviceRooms.get(duid) ?? [];
const roomMappings = mapInfo.allRooms.map((dto) => HomeModelMapper.toRoomMapping(dto, rooms));
const homeEntity = new HomeEntity(0, '', new RoomMap(roomMappings), mapInfo, 0);
this.applyAreasResult(duid, getSupportedAreas(homeEntity, this.logger, this.enableMultipleMap));
}
else if (mapInfo.maps.length > 0) {
this.setSupportedMaps(duid, toSupportedMaps(mapInfo, this.enableMultipleMap));
}
return mapInfo;
}
applyAreasResult(duid, result, mergeMapId) {
this.setSupportedMaps(duid, result.supportedMaps);
if (this.enableMultipleMap && mergeMapId !== undefined) {
this.mergeSupportedAreasForMap(duid, mergeMapId, result.supportedAreas, result.roomIndexMap);
return;
}
this.setSupportedAreaIndexMap(duid, result.roomIndexMap);
this.setSupportedAreas(duid, result.supportedAreas);
}
/** Publish areas/maps from a computed {@link SupportedAreasResult} (live push path). */
applySupportedAreasResult(duid, result, mergeMapId) {
this.applyAreasResult(duid, result, mergeMapId);
}
isPhysicalMapId(mapId) {
return mapId >= 0 && mapId !== ROUTINE_MAP_ID;
}
hasAreasForMap(duid, mapId) {
return this.getSupportedAreas(duid).some((a) => a.mapId === mapId);
}
applyRoomMapData(duid, rawData, expectedMapId) {
if (!rawData || rawData.length === 0) {
return undefined;
}
const storedMapInfo = this.mapInfoCache.get(duid) ?? MapInfo.empty();
const resolvedMapId = storedMapInfo.resolveMapIdForRoomData(rawData, expectedMapId);
if (!this.enableMultipleMap) {
const primaryMapId = this.getPrimaryMapId(duid);
if (primaryMapId !== undefined && resolvedMapId !== primaryMapId) {
return undefined;
}
}
const rooms = this.deviceRooms.get(duid) ?? [];
const roomMappings = HomeModelMapper.rawRoomDataToRoomMappings(rawData, resolvedMapId, storedMapInfo, rooms);
const homeEntity = new HomeEntity(0, '', new RoomMap(roomMappings), storedMapInfo, 0);
this.applyAreasResult(duid, getSupportedAreas(homeEntity, this.logger, this.enableMultipleMap), this.enableMultipleMap ? resolvedMapId : undefined);
return resolvedMapId;
}
async fetchAndApplyRoomMap(duid, activeMap, expectedMapId) {
if (!this.serviceRouting) {
throw new DeviceError('Service routing not initialized', duid);
}
const rawData = await this.serviceRouting.getRoomMap(duid, activeMap);
return this.applyRoomMapData(duid, rawData, expectedMapId);
}
async ensureAreasForMap(duid, mapId, options) {
if (!this.isPhysicalMapId(mapId)) {
return true;
}
if (!this.enableMultipleMap) {
const primaryMapId = this.getPrimaryMapId(duid);
if (primaryMapId !== undefined && mapId !== primaryMapId) {
return this.hasAreasForMap(duid, mapId);
}
}
if (this.hasAreasForMap(duid, mapId)) {
return true;
}
try {
if (options?.switchFirst === true && this.serviceRouting) {
await this.serviceRouting.switchMap(duid, mapId);
}
await this.fetchAndApplyRoomMap(duid, mapId, mapId);
}
catch (err) {
this.logger.error(`AreaManagementService - ensureAreasForMap failed for ${duid} map ${mapId}: ${String(err)}`);
return false;
}
return this.hasAreasForMap(duid, mapId);
}
async getMapInfo(duid) {
this.logger.debug('AreaManagementService - getMapInfo', duid);
if (this.liveMapUpdates) {
if (!this.serviceRouting) {
throw new DeviceError('Service routing not initialized', duid);
}
await this.serviceRouting.getMapInfoV2(duid);
return undefined;
}
return this.fetchAndApplyMapInfo(duid);
}
async getRoomMap(duid, activeMap) {
this.logger.debug('AreaManagementService - getRoomMap', duid);
if (this.liveMapUpdates) {
if (!this.serviceRouting) {
throw new DeviceError('Service routing not initialized', duid);
}
await this.serviceRouting.getRoomMapV2(duid, activeMap);
return undefined;
}
if (!this.serviceRouting) {
throw new DeviceError('Service routing not initialized', duid);
}
const rawData = await this.serviceRouting.getRoomMap(duid, activeMap);
this.applyRoomMapData(duid, rawData);
return rawData;
}
async resolveInitialAreas(duid) {
this.logger.debug('AreaManagementService - resolveInitialAreas', duid);
try {
const mapInfo = await this.fetchAndApplyMapInfo(duid);
const originalActiveMapId = await this.fetchAndApplyRoomMap(duid, -1);
if (this.enableMultipleMap && mapInfo && this.serviceRouting) {
for (const map of mapInfo.maps) {
if (!this.isPhysicalMapId(map.id)) {
continue;
}
if (this.hasAreasForMap(duid, map.id)) {
continue;
}
await this.ensureAreasForMap(duid, map.id, { switchFirst: true });
}
if (originalActiveMapId !== undefined && this.isPhysicalMapId(originalActiveMapId)) {
try {
await this.serviceRouting.switchMap(duid, originalActiveMapId);
}
catch (err) {
this.logger.warn(`AreaManagementService - resolveInitialAreas failed to restore active map ${originalActiveMapId} for ${duid}: ${String(err)}`);
}
}
this.sortSupportedAreasByMap(duid);
}
}
catch (err) {
this.logger.error(`AreaManagementService - resolveInitialAreas failed for ${duid}: ${String(err)}`);
}
const supportedAreas = this.getSupportedAreas(duid);
const supportedMaps = this.getSupportedMaps(duid);
return { supportedAreas, supportedMaps };
}
sortSupportedAreasByMap(duid) {
const existingAreas = this.getSupportedAreas(duid);
const existingIndexMap = this.getSupportedAreasIndexMap(duid);
const indexedAreas = existingAreas.map((area, oldAreaId) => ({ area, oldAreaId }));
const sortedIndexedAreas = [...indexedAreas].sort((a, b) => (a.area.mapId ?? 0) - (b.area.mapId ?? 0));
const sortedAreas = sortedIndexedAreas.map(({ area }, newAreaId) => ({ ...area, areaId: newAreaId }));
const areaInfos = new Map();
const roomInfos = new Map();
sortedIndexedAreas.forEach(({ oldAreaId }, newAreaId) => {
const info = existingIndexMap?.areaInfo.get(oldAreaId);
if (!info) {
return;
}
areaInfos.set(newAreaId, info);
const mapId = info.mapId ?? 0;
roomInfos.set(`${info.roomId}-${mapId}`, {
areaId: newAreaId,
mapId,
roomName: info.roomName,
});
});
const sortedIndexMap = new RoomIndexMap(areaInfos, roomInfos);
this.setSupportedAreaIndexMap(duid, sortedIndexMap);
this.setSupportedAreas(duid, sortedAreas);
}
startPeriodicRefresh(duid, intervalMs = 5 * 60 * 1000) {
this.stopPeriodicRefresh(duid);
const handle = setInterval(() => {
this.logger.debug(`AreaManagementService - periodic area refresh for ${duid}`);
this.getMapInfo(duid).catch((err) => {
this.logger.error(`AreaManagementService - getMapInfo refresh failed for ${duid}: ${String(err)}`);
});
}, intervalMs);
this.refreshIntervals.set(duid, handle);
}
stopPeriodicRefresh(duid) {
const handle = this.refreshIntervals.get(duid);
if (handle) {
clearInterval(handle);
this.refreshIntervals.delete(duid);
}
}
async getScenes(homeId) {
if (!this.iotApi) {
throw new DeviceError('IoT API not initialized');
}
return this.iotApi.getScenes(homeId);
}
async startScene(sceneId) {
if (!this.iotApi) {
throw new DeviceError('IoT API not initialized');
}
return this.iotApi.startScene(sceneId);
}
setV1ResolvedSegment(duid, segmentId) {
this.v1RoomResolutionCache.set(duid, { segmentId, resolvedAtMs: Date.now() });
}
getV1ResolvedSegment(duid, maxAgeMs = 30_000) {
const cached = this.v1RoomResolutionCache.get(duid);
if (!cached)
return undefined;
if (Date.now() - cached.resolvedAtMs > maxAgeMs)
return undefined;
return cached.segmentId;
}
async requestV1MapRefresh(duid) {
if (!this.serviceRouting)
return;
try {
await this.serviceRouting.requestHomeMapPush(duid);
}
catch (err) {
this.logger.debug(`[${duid}] requestV1MapRefresh failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Clear all area management data and stop all refresh timers. */
clearAll() {
for (const duid of this.refreshIntervals.keys()) {
this.stopPeriodicRefresh(duid);
}
this.supportedAreas.clear();
this.supportedMaps.clear();
this.supportedRoutines.clear();
this.selectedAreas.clear();
this.progress.clear();
this.lastActivelyCleaningState.clear();
this.supportedAreaIndexMaps.clear();
this.areasListeners.clear();
this.deviceRooms.clear();
this.mapInfoCache.clear();
this.v1RoomResolutionCache.clear();
this.logger.debug('AreaManagementService - All data cleared');
}
}
//# sourceMappingURL=areaManagementService.js.map