UNPKG

matterbridge-roborock-vacuum-plugin

Version:
345 lines 14.8 kB
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