UNPKG

iobroker.roborock

Version:
501 lines (454 loc) 21.1 kB
import type { Roborock } from "../../main"; import { B01MapPipeline } from "./b01/B01MapPipeline"; import type { Q10PayloadClassification } from "./b01/B01MapPayloadClassifier"; import { MapBuilder as MapBuilderB01 } from "./b01/MapBuilder"; import { B01DeviceStatus, B01MapData, Q10RuntimeDebugSummary } from "./b01/types"; import { Q10MapBuilder } from "./q10/Q10MapBuilder"; import { Q10MapCreator } from "./q10/Q10MapCreator"; import { applyQ10PathOnlyToB01, applyQ10RuntimeStatePatch, mergeQ10RuntimeState } from "./q10/Q10YxMapParser"; import type { Q10RuntimeStatePatch, Q10SourcePathPoint } from "./q10/types"; import { MapBuilder as MapBuilderV1 } from "./v1/MapBuilder"; import { MapDecryptor as MapDecryptorV1 } from "./v1/MapDecryptor"; import { MapParser as MapParserV1 } from "./v1/MapParser"; export class MapManager { private adapter: Roborock; public mapParser: MapParserV1; public mapCreator: MapBuilderV1; private pipelineB01: B01MapPipeline; private builderB01: MapBuilderB01; private creatorQ10: Q10MapCreator; private builderQ10: Q10MapBuilder; private q10StateByDevice = new Map<string, B01MapData>(); private q10PendingPathPreludeByDevice = new Map<string, { pathPoints: Q10SourcePathPoint[]; receivedAt: number }>(); private latestB01DeviceStatusByDevice = new Map<string, Partial<B01DeviceStatus>>(); private static readonly NON_Q10_CLASSIFICATION: Q10PayloadClassification = { isQ10Payload: false, isLiveMapCandidate: false, payloadShape: "map", blobType: null, mapData: null, pathPoints: null }; private static readonly EMPTY_Q10_OVERLAY_COUNTS = { virtualWalls: 0, forbidAreas: 0, mopAreas: 0, thresholdAreas: 0, eraseAreas: 0, carpetAreas: 0 }; private static readonly Q10_PATH_PRELUDE_TTL_MS = 30_000; constructor(adapter: Roborock) { this.adapter = adapter; this.mapParser = new MapParserV1(adapter); this.mapCreator = new MapBuilderV1(adapter); this.pipelineB01 = new B01MapPipeline(adapter); this.builderB01 = new MapBuilderB01(adapter); this.creatorQ10 = new Q10MapCreator(adapter); this.builderQ10 = new Q10MapBuilder(adapter); } /** * Processes raw map data and returns a generated map buffer. * @param rawData The raw buffer from the robot (Protocol 301). * @param version The protocol version string (e.g., "B01" or "1.0"). * @param model The robot model (used for key derivation/assets). * @param serial The robot serial (used for key derivation). * @param mappedRooms Optional room mapping for V1. * @param currentMapIndex Optional floor index for V1; when set and mappedRooms empty, segment names are enriched from room states. */ public async processMap(rawData: Buffer, version: string, model: string, serial: string, mappedRooms: any[] | null, duid?: string, connectionType: string = "Unknown", deviceStatus?: B01DeviceStatus, currentMapIndex?: number): Promise<{ mapBase64: string, mapBase64Clean?: string, mapData?: any } | null> { try { if (version === "B01" || version === "Q10") { const resolved = this.pipelineB01.resolve(rawData, version, model, serial, duid || "", connectionType); if (resolved?.variant === "q10") { const effectiveDeviceStatus = duid ? await this.getDeviceStatusForB01(duid, deviceStatus) : deviceStatus; const q10Result = await this.processQ10Payload( { classification: resolved.q10, mapData: resolved.mapData }, duid, connectionType, effectiveDeviceStatus, model || undefined ); if (q10Result) { return q10Result; } } if (resolved?.variant === "protobuf") { const mapData = resolved.mapData; const effectiveDeviceStatus = duid ? await this.getDeviceStatusForB01(duid, deviceStatus) : deviceStatus; const expectedGridSize = mapData.header.sizeX * mapData.header.sizeY; // Only accept when grid length exactly matches header (real maps); reject wrong decryption, fragments, or non-map packets. if (expectedGridSize > 0 && mapData.mapGrid.length !== expectedGridSize) { this.adapter.rLog(connectionType as any, duid || "unknown", "Warn", version, 301, `B01 map rejected: grid size inconsistent with header (got ${mapData.mapGrid.length}, expected sizeX*sizeY=${expectedGridSize})`, "warn"); } else { const mapBuf = await this.builderB01.buildMap(mapData, model, duid, effectiveDeviceStatus); const mapBase64 = "data:image/png;base64," + mapBuf.toString("base64"); return { mapBase64: mapBase64, mapBase64Clean: mapBase64, // Reuse same map for clean view for now mapData: mapData }; } } } else { // V1 Handling with MapDecryptor (GZIP) const mapBuf = await MapDecryptorV1.decrypt(rawData); if (!mapBuf) { this.adapter.rLog("MapManager", duid || null, "Error", version, 301, `Failed to unzip V1 map data`, "error"); return null; } // V1 parser returns ParsedMapData OR empty object const mapData = await this.mapParser.parsedata(mapBuf, mappedRooms, { isHistoryMap: false, duid: duid ?? undefined }); // For cloud robots mappedRooms may be empty; enrich segment names from room states when possible if (mapData && Object.keys(mapData).length > 0 && duid != null && "IMAGE" in mapData) { const floor = (currentMapIndex != null && currentMapIndex >= 0) ? currentMapIndex : 0; const list = mapData.IMAGE?.segments?.list; if (Array.isArray(list) && (!mappedRooms || mappedRooms.length === 0)) { for (const seg of list) { if (seg.id != null && !seg.name) { const obj = await this.adapter.getObjectAsync(`Devices.${duid}.floors.${floor}.${seg.id}`); const name = (obj as any)?.common?.name; if (name && String(name).trim()) seg.name = String(name).trim(); } } } } if (mapData && Object.keys(mapData).length > 0) { // Legacy MapCreator returns [clean, full] // We cast builderV1 to any to avoid type issues if CanvasMap isn't explicitly typed in class definition yet const [mapBase64Clean, mapBase64] = await this.mapCreator.canvasMap(mapData, { mappedRooms, model, duid: duid ?? undefined }); return { mapBase64: mapBase64, mapBase64Clean: mapBase64Clean, mapData: mapData }; } } } catch (e: unknown) { this.adapter.rLog("MapManager", duid || null, "Error", version, 301, `Failed to process map (Version: ${version}): ${this.adapter.errorMessage(e)}`, "error"); } return null; } private async processQ10Payload( q10Payload: { classification: Q10PayloadClassification; mapData: B01MapData | null }, duid?: string, connectionType: string = "Unknown", deviceStatus?: B01DeviceStatus, robotModel?: string ): Promise<{ mapBase64: string, mapBase64Clean?: string, mapData?: any } | null> { const cacheKey = this.getQ10CacheKey(duid, connectionType); const previous = this.q10StateByDevice.get(cacheKey); const { classification } = q10Payload; const packetKind: "full" | "path-only" = classification.mapData ? "full" : "path-only"; const rawMapData = q10Payload.mapData ?? classification.mapData; const rawOverlayCounts = rawMapData?.q10RawOverlayCounts ?? this.getQ10OverlayCounts(rawMapData); const sourceOverlayCounts = this.getQ10OverlayCounts(rawMapData); let overlaySeedSource: "inline" | "runtime-cache" | "none" = this.hasQ10OverlaySeed(rawMapData) ? "inline" : "none"; let mapData = rawMapData; if (mapData) { if (connectionType !== "B01History") { const pendingPrelude = this.consumeQ10PendingPathPrelude(cacheKey); if (pendingPrelude && !(mapData.q10SourceData?.pathPoints?.length ?? 0)) { mapData = applyQ10PathOnlyToB01(mapData, pendingPrelude); } mapData = mergeQ10RuntimeState(mapData, previous); if ( overlaySeedSource === "none" && this.hasQ10OverlaySeed(mapData) && this.isCompatibleQ10OverlaySeed(rawMapData!, previous) ) { overlaySeedSource = "runtime-cache"; } } } else { const pathPoints = classification.pathPoints; if (!pathPoints?.length) { return null; } if (!previous) { if (connectionType !== "B01History") { this.storeQ10PendingPathPrelude(cacheKey, pathPoints); } return null; } mapData = applyQ10PathOnlyToB01(previous, pathPoints); if (overlaySeedSource === "none" && this.hasQ10OverlaySeed(mapData)) { overlaySeedSource = "runtime-cache"; } } const created = this.creatorQ10.create(mapData, deviceStatus); created.q10RuntimeDebug = this.buildQ10RuntimeDebugSummary( created, packetKind, classification, rawOverlayCounts, sourceOverlayCounts, overlaySeedSource ); const resolvedRobotModel = robotModel || (duid ? this.adapter.http_api?.getRobotModel(duid) || undefined : undefined); const rendered = await this.builderQ10.buildMaps(created, deviceStatus, resolvedRobotModel); const mapBase64 = "data:image/png;base64," + rendered.full.toString("base64"); const mapBase64Clean = "data:image/png;base64," + rendered.clean.toString("base64"); this.q10StateByDevice.set(cacheKey, created); return { mapBase64, mapBase64Clean, mapData: created }; } public async applyQ10LiveStatePatch(duid: string, patch: Q10RuntimeStatePatch): Promise<boolean> { if (!duid) return false; const cacheKey = this.getQ10CacheKey(duid, "B01"); const current = this.q10StateByDevice.get(cacheKey); if (!current?.q10SourceData) return false; const patched = applyQ10RuntimeStatePatch(current, patch); if (patched === current) return false; const deviceStatus = await this.getDeviceStatusForB01(duid); const robotModel = this.adapter.http_api?.getRobotModel(duid) || undefined; const created = this.creatorQ10.create(patched, deviceStatus); created.q10RuntimeDebug = this.buildQ10RuntimeDebugSummary( created, "full", MapManager.NON_Q10_CLASSIFICATION, current.q10RawOverlayCounts ?? this.getQ10OverlayCounts(current), this.getQ10OverlayCounts(created), current.q10RuntimeDebug?.overlaySeedSource ?? "none" ); const rendered = await this.builderQ10.buildMaps(created, deviceStatus, robotModel); const result = { mapBase64: "data:image/png;base64," + rendered.full.toString("base64"), mapBase64Clean: "data:image/png;base64," + rendered.clean.toString("base64"), mapData: created }; this.q10StateByDevice.set(cacheKey, created); await this.saveGeneratedMap(duid, result); return true; } private buildQ10RuntimeDebugSummary( mapData: B01MapData, packetKind: "full" | "path-only", classification: Q10PayloadClassification = MapManager.NON_Q10_CLASSIFICATION, rawOverlayCounts: { virtualWalls: number; forbidAreas: number; mopAreas: number; thresholdAreas: number; eraseAreas: number; carpetAreas: number; } = MapManager.EMPTY_Q10_OVERLAY_COUNTS, sourceOverlayCounts: { virtualWalls: number; forbidAreas: number; mopAreas: number; thresholdAreas: number; eraseAreas: number; carpetAreas: number; } = MapManager.EMPTY_Q10_OVERLAY_COUNTS, overlaySeedSource: "inline" | "runtime-cache" | "none" = "none" ): Q10RuntimeDebugSummary { const verification = mapData.q10Verification; return { packetKind, payloadShape: classification.payloadShape, overlaySeedSource, overlaySeedHydrated: overlaySeedSource === "runtime-cache", rawVirtualWalls: rawOverlayCounts.virtualWalls, rawForbidAreas: rawOverlayCounts.forbidAreas, rawMopAreas: rawOverlayCounts.mopAreas, rawThresholdAreas: rawOverlayCounts.thresholdAreas, rawEraseAreas: rawOverlayCounts.eraseAreas, rawCarpetAreas: rawOverlayCounts.carpetAreas, sourceVirtualWalls: sourceOverlayCounts.virtualWalls, sourceForbidAreas: sourceOverlayCounts.forbidAreas, sourceMopAreas: sourceOverlayCounts.mopAreas, sourceThresholdAreas: sourceOverlayCounts.thresholdAreas, sourceEraseAreas: sourceOverlayCounts.eraseAreas, sourceCarpetAreas: sourceOverlayCounts.carpetAreas, pathPoints: mapData.q10SourceData?.pathPoints.length ?? mapData.q10CreatorData?.pathPixels.length ?? 0, historyPoints: mapData.history?.length ?? 0, virtualWalls: mapData.q10SourceData?.virtualWalls.length ?? mapData.virtualWalls?.length ?? 0, forbidAreas: mapData.q10SourceData?.forbidAreas.length ?? mapData.recmForbitZone?.length ?? 0, mopAreas: mapData.q10SourceData?.mopAreas.length ?? 0, thresholdAreas: mapData.q10SourceData?.thresholdAreas.length ?? mapData.thresholds?.length ?? 0, eraseAreas: mapData.q10SourceData?.eraseAreas.length ?? mapData.eraseAreas?.length ?? 0, carpetAreas: mapData.q10SourceData?.carpetAreas.length ?? mapData.carpetInfo?.length ?? 0, obstacles: mapData.q10SourceData?.obstacles.length ?? mapData.obstacles?.length ?? 0, skipPoints: mapData.q10SourceData?.skipPoints.length ?? mapData.skipCleanPoints?.length ?? 0, suspectedPoints: mapData.q10SourceData?.suspectedPoints.length ?? mapData.q10CreatorData?.suspectedPoints.length ?? 0, rooms: mapData.q10SourceData?.rooms.length ?? mapData.rooms?.length ?? 0, robotPresent: !!(mapData.q10CreatorData?.robotPixel || mapData.robotPos), chargerPresent: !!(mapData.q10CreatorData?.chargerPixel || mapData.chargerPos), presentVerifiedFeatures: verification?.presentVerifiedFeatures ?? [], presentUnverifiedFeatures: verification?.presentUnverifiedFeatures ?? [] }; } private getQ10CacheKey(duid?: string, connectionType: string = "Unknown"): string { const scope = connectionType === "B01History" ? "history" : "live"; return `${duid || "unknown"}:${scope}`; } private storeQ10PendingPathPrelude(cacheKey: string, pathPoints: Q10SourcePathPoint[]): void { this.q10PendingPathPreludeByDevice.set(cacheKey, { pathPoints: pathPoints.map((point) => ({ ...point })), receivedAt: Date.now() }); } private consumeQ10PendingPathPrelude(cacheKey: string): Q10SourcePathPoint[] | null { const pending = this.q10PendingPathPreludeByDevice.get(cacheKey); if (!pending) return null; this.q10PendingPathPreludeByDevice.delete(cacheKey); if (Date.now() - pending.receivedAt > MapManager.Q10_PATH_PRELUDE_TTL_MS) { return null; } return pending.pathPoints.map((point) => ({ ...point })); } private hasQ10OverlaySeed(mapData?: B01MapData | null): boolean { const source = mapData?.q10SourceData; if (!source) return false; return [ source.virtualWalls, source.forbidAreas, source.mopAreas, source.thresholdAreas, source.eraseAreas, source.carpetAreas ].some((areas) => (areas?.length ?? 0) > 0); } private getQ10OverlayCounts(mapData?: B01MapData | null): { virtualWalls: number; forbidAreas: number; mopAreas: number; thresholdAreas: number; eraseAreas: number; carpetAreas: number; } { const source = mapData?.q10SourceData; if (!source) return { ...MapManager.EMPTY_Q10_OVERLAY_COUNTS }; return { virtualWalls: source.virtualWalls.length, forbidAreas: source.forbidAreas.length, mopAreas: source.mopAreas.length, thresholdAreas: source.thresholdAreas.length, eraseAreas: source.eraseAreas.length, carpetAreas: source.carpetAreas.length }; } private isCompatibleQ10OverlaySeed(current: B01MapData, candidate?: B01MapData | null): candidate is B01MapData { if (!candidate?.q10SourceData) return false; if (!this.hasQ10OverlaySeed(candidate)) return false; const currentMapId = current.q10SourceData?.mapId; const candidateMapId = candidate.q10SourceData?.mapId; if ( Number.isFinite(currentMapId) && Number.isFinite(candidateMapId) && currentMapId && candidateMapId && currentMapId !== candidateMapId ) { return false; } const currentHeader = current.header; const candidateHeader = candidate.header; if (currentHeader.sizeX !== candidateHeader.sizeX || currentHeader.sizeY !== candidateHeader.sizeY) { return false; } const tolerance = Math.max(currentHeader.resolution, candidateHeader.resolution, 0.05) * 2; return ( Math.abs(currentHeader.minX - candidateHeader.minX) <= tolerance && Math.abs(currentHeader.minY - candidateHeader.minY) <= tolerance && Math.abs(currentHeader.maxX - candidateHeader.maxX) <= tolerance && Math.abs(currentHeader.maxY - candidateHeader.maxY) <= tolerance && Math.abs(currentHeader.resolution - candidateHeader.resolution) <= tolerance ); } public updateB01DeviceStatus(duid: string, status: Partial<B01DeviceStatus>): void { if (!duid) return; const current = this.latestB01DeviceStatusByDevice.get(duid) ?? {}; this.latestB01DeviceStatusByDevice.set(duid, { ...current, ...status }); } private async readPersistedB01DeviceStatus(duid: string): Promise<Partial<B01DeviceStatus>> { const getVal = async (keys: string[]): Promise<any | undefined> => { for (const k of keys) { const s = await this.adapter.getStateAsync(`Devices.${duid}.deviceStatus.${k}`); if (s && s.val !== undefined && s.val !== null) return s.val; } return undefined; }; const stateVal = await getVal(["status", "state", "4"]); const workModeVal = await getVal(["work_mode", "workMode", "15"]); const cleanModeVal = await getVal(["mode", "cleanMode", "17"]); const dustCollectVal = await getVal(["dust_action", "dust_collection_status", "105"]); const faultVal = await getVal(["fault", "deviceFault", "18"]); const persisted: Partial<B01DeviceStatus> = {}; if (stateVal !== undefined) persisted.deviceState = Number(stateVal); if (workModeVal !== undefined) persisted.deviceWorkMode = Number(workModeVal); if (cleanModeVal !== undefined) persisted.deviceCleanMode = Number(cleanModeVal); if (dustCollectVal !== undefined) { persisted.isDustCollect = dustCollectVal === 1 || dustCollectVal === true || dustCollectVal === "1"; } if (faultVal !== undefined) persisted.deviceFault = Number(faultVal); return persisted; } private pickB01StatusValue<T>(...values: Array<T | null | undefined>): T | undefined { for (const value of values) { if (value !== undefined && value !== null) return value; } return undefined; } private async getDeviceStatusForB01(duid: string, preferred?: Partial<B01DeviceStatus>): Promise<B01DeviceStatus> { const persisted = await this.readPersistedB01DeviceStatus(duid); const cached = this.latestB01DeviceStatusByDevice.get(duid); return { deviceState: this.pickB01StatusValue(preferred?.deviceState, cached?.deviceState, persisted.deviceState, 0) ?? 0, deviceWorkMode: this.pickB01StatusValue(preferred?.deviceWorkMode, cached?.deviceWorkMode, persisted.deviceWorkMode, 0) ?? 0, deviceCleanMode: this.pickB01StatusValue(preferred?.deviceCleanMode, cached?.deviceCleanMode, persisted.deviceCleanMode, 0), deviceChargeState: this.pickB01StatusValue(preferred?.deviceChargeState, cached?.deviceChargeState, persisted.deviceChargeState), isDustCollect: this.pickB01StatusValue(preferred?.isDustCollect, cached?.isDustCollect, persisted.isDustCollect, false) ?? false, deviceFault: this.pickB01StatusValue(preferred?.deviceFault, cached?.deviceFault, persisted.deviceFault, 0), deviceQuiet: this.pickB01StatusValue(preferred?.deviceQuiet, cached?.deviceQuiet, persisted.deviceQuiet), devicePvCutCharge: this.pickB01StatusValue(preferred?.devicePvCutCharge, cached?.devicePvCutCharge, persisted.devicePvCutCharge), deviceBattery: this.pickB01StatusValue(preferred?.deviceBattery, cached?.deviceBattery, persisted.deviceBattery), deviceCustomType: this.pickB01StatusValue(preferred?.deviceCustomType, cached?.deviceCustomType, persisted.deviceCustomType) }; } /** * Saves the generated map results to ioBroker states. * @param duid Device Unique ID * @param res The processed map result object */ public async saveGeneratedMap(duid: string, res: { mapBase64: string, mapBase64Clean?: string, mapData?: any }): Promise<void> { if (!res) return; try { await this.adapter.ensureFolder(`Devices.${duid}.map`); const tasks: Promise<any>[] = []; if (res.mapBase64) { tasks.push( this.adapter.ensureState(`Devices.${duid}.map.mapBase64`, { name: "Map Image", type: "string", role: "text.png" }) .then(() => this.adapter.setStateChangedAsync(`Devices.${duid}.map.mapBase64`, { val: res.mapBase64, ack: true })) ); } if (res.mapBase64Clean) { tasks.push( this.adapter.ensureState(`Devices.${duid}.map.mapBase64Clean`, { name: "Map Image (Clean)", type: "string", role: "text.png" }) .then(() => this.adapter.setStateChangedAsync(`Devices.${duid}.map.mapBase64Clean`, { val: res.mapBase64Clean, ack: true })) ); } if (res.mapData) { tasks.push( this.adapter.ensureState(`Devices.${duid}.map.mapData`, { name: "Map Data", type: "string", role: "json" }) .then(() => this.adapter.setStateChangedAsync(`Devices.${duid}.map.mapData`, { val: JSON.stringify(res.mapData), ack: true })) ); } await Promise.all(tasks); } catch (e: unknown) { this.adapter.rLog("MapManager", duid, "Error", "Map", undefined, `Failed to save map states: ${this.adapter.errorMessage(e)}`, "error"); } } }