UNPKG

iobroker.roborock

Version:
351 lines (296 loc) 14.9 kB
import { promisify } from "node:util"; import { gunzip } from "node:zlib"; import { MapManager } from "../../../map/MapManager"; import type { FeatureDependencies } from "../../baseDeviceFeatures"; const gunzipAsync = promisify(gunzip); export class V1MapService { public mapManager: MapManager; private adapter: FeatureDependencies["adapter"]; // Mapped rooms cache (currently seems unused/null in V1VacuumFeatures but kept for compatibility) private mappedRooms: any[] | null = null; private currentMapIndex: number = -1; public get currentIndex(): number { return this.currentMapIndex; } private multiMaps: any[] = []; private lastMapStatus: number = -1; constructor( private deps: FeatureDependencies, private duid: string ) { this.adapter = deps.adapter; this.mapManager = new MapManager(this.adapter); } public async updateMap(): Promise<void> { try { // "get_map_v1" is usually the command to GET the map. const result = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_map_v1", [], { priority: 0 }); let mapBuf: Buffer | undefined; let mapVer = await this.deps.adapter.getDeviceProtocolVersion(this.duid); const robotModel = this.deps.adapter.http_api.getRobotModel(this.duid) || ""; // Handle new return format { data, version } or legacy Buffer if (result && typeof result === "object" && "data" in result && Buffer.isBuffer((result as any).data)) { mapBuf = (result as any).data; if ((result as any).version) mapVer = (result as any).version; } else if (Buffer.isBuffer(result)) { mapBuf = result; } else if (result) { this.adapter.rLog("System", this.duid, "Debug", undefined, undefined, `get_map_v1 returned non-buffer: ${typeof result}`, "debug"); } if (mapBuf) { const mapResult = await this.mapManager.processMap(mapBuf, mapVer, robotModel, this.duid, this.mappedRooms, this.duid, "Unknown", undefined, this.currentMapIndex); if (mapResult) { await this.processMapResults(mapResult); } } } catch (e: any) { this.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update map: ${e.message}`, "warn"); } } private async processMapResults(mapResult: { mapBase64: string, mapBase64Clean?: string, mapData?: any } | null): Promise<void> { if (!mapResult) return; await this.mapManager.saveGeneratedMap(this.duid, mapResult); // Logic to determine current floor (mapFlag) based on active map slot if (this.lastMapStatus !== -1 && this.lastMapStatus < 250) { const slotIndex = this.lastMapStatus >> 2; if (slotIndex >= 0 && slotIndex !== this.currentMapIndex) { this.currentMapIndex = slotIndex; this.adapter.rLog("MapManager", this.duid, "Debug", "1.0", undefined, `Updated current map index to ${this.currentMapIndex} from status ${this.lastMapStatus}`, "debug"); } } // Only create room states for segments that are on this map – ensures rooms are 100% assigned to this floor if (mapResult.mapData && mapResult.mapData.IMAGE && mapResult.mapData.IMAGE.segments && Array.isArray(mapResult.mapData.IMAGE.segments.list)) { const currentMapFlag = this.currentMapIndex; await this.deps.ensureFolder(`Devices.${this.duid}.floors`); await this.deps.ensureFolder(`Devices.${this.duid}.floors.${currentMapFlag}`); for (const segment of mapResult.mapData.IMAGE.segments.list) { const id = segment.id; // Skip invalid IDs if any if (!id) continue; const roomStateId = `Devices.${this.duid}.floors.${currentMapFlag}.${id}`; // We update the name even if the state exists, IF the state name is empty or numeric const segmentName = segment.name; let finalName = segmentName || ""; if (String(finalName).match(/^\d+$/)) { finalName = ""; } const obj = await this.adapter.getObjectAsync(roomStateId); const currentName = obj?.common?.name; // If we have a new name, or if state doesn't exist, or if current name is just a number/invalid const isInvalidName = !currentName || String(currentName).match(/^\d+$/); if (!obj || (finalName && currentName !== finalName) || (isInvalidName && finalName !== currentName)) { if (!obj) { this.adapter.rLog("MapManager", this.duid, "Info", "1.0", undefined, `Found new room in map (ID: ${id}, Name: "${finalName}"). Adding state.`, "info"); } else if (finalName) { this.adapter.rLog("MapManager", this.duid, "Debug", "1.0", undefined, `Updating room name for ${roomStateId}: "${currentName}" -> "${finalName}"`, "debug"); } const common: Partial<ioBroker.StateCommon> = { type: "boolean", role: "switch", write: true, name: finalName || (obj?.common?.name as string) || "", def: false }; await this.deps.ensureState(roomStateId, common); await this.adapter.extendObject(roomStateId, { native: { id: id } }); } } } } public async getCleaningRecordMap(startTime: number): Promise<{ mapBase64CleanUncropped: string; mapBase64: string; mapData: string } | null> { try { // B01 devices are handled via override/other logic. // V1 devices (1.0) expect an object with start_time. const params = { start_time: startTime }; const cleaningRecordMapRes = (await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_clean_record_map", params, { priority: -10 })); // LOW let unwrapped = cleaningRecordMapRes; while (Array.isArray(unwrapped) && unwrapped.length === 1) { unwrapped = unwrapped[0]; } let cleaningRecordMap: Buffer; // Check new return format { data, version } or legacy Buffer if (unwrapped && typeof unwrapped === "object" && "data" in unwrapped && Buffer.isBuffer((unwrapped as any).data)) { cleaningRecordMap = (unwrapped as any).data; } else if (Buffer.isBuffer(unwrapped)) { cleaningRecordMap = unwrapped; } else { return null; } const t0 = Date.now(); // Check if map is gzipped (starts with 0x1f 0x8b) let mapBuf: Buffer = cleaningRecordMap; if (cleaningRecordMap[0] === 0x1f && cleaningRecordMap[1] === 0x8b) { try { mapBuf = await gunzipAsync(cleaningRecordMap); } catch (e) { this.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Failed to unzip map data: ${e}`, "error"); return null; } } const t1 = Date.now(); const mapData = await this.deps.adapter.mapManager.mapParser.parsedata(mapBuf, null, { isHistoryMap: true }); if (!mapData) { return null; } const t2 = Date.now(); // Generate images const [mapBase64CleanUncropped, mapBase64] = await this.deps.adapter.mapManager.mapCreator.canvasMap(mapData); const t3 = Date.now(); this.adapter.rLog("MapManager", this.duid, "Debug", "Profiler", undefined, `[MapProfiler] History Map ${startTime} processed. Total: ${t3 - t0}ms | Unzip: ${t1 - t0}ms | Parse: ${t2 - t1}ms | Canvas: ${t3 - t2}ms | Size: ${cleaningRecordMap.length}`, "debug"); return { mapBase64CleanUncropped, mapBase64, mapData: JSON.stringify(mapData), }; } catch (e: any) { this.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to get cleaning record map: ${e.message}`, "warn"); return null; } } /** * Fetches room mapping from API and stores it for name resolution when the map is loaded. * Room states are NOT created here – they are only created in processMapResults when segments * are present on the loaded map, so rooms exist only where they are 100% assigned to that floor. */ public async updateRoomMapping(): Promise<boolean> { try { let rawResult: any = null; for (let i = 0; i < 3; i++) { rawResult = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_room_mapping", []); const hasMapInfo = rawResult && typeof rawResult === "object" && ((Array.isArray(rawResult) && rawResult[0] && (rawResult[0] as any).map_info) || (rawResult as any).map_info); if (hasMapInfo || (Array.isArray(rawResult) && rawResult.length > 0)) { break; } if (i < 2) await new Promise(resolve => setTimeout(resolve, 2000)); } const mapInfoFromApi: any[] | undefined = Array.isArray(rawResult) && rawResult[0] && (rawResult[0] as any).map_info ? (rawResult[0] as any).map_info : (rawResult && (rawResult as any).map_info); const hasRoomsPerMap = Array.isArray(mapInfoFromApi) && mapInfoFromApi.some((m: any) => Array.isArray(m.rooms) && m.rooms.length > 0); let stored = false; if (hasRoomsPerMap) { // Store per-map room list for parser name resolution; do not create any room states here for (const map of mapInfoFromApi) { const mapFlag = map.mapFlag ?? map.id; const rooms = map.rooms; if (!Array.isArray(rooms) || rooms.length === 0) continue; const roomEntries: [number, number][] = rooms.map((r: any) => [r.id, r.iot_name_id != null ? r.iot_name_id : 0]); if (this.currentMapIndex === mapFlag) this.mappedRooms = roomEntries; const existing = this.multiMaps.find((m: any) => (m.mapFlag ?? m.id) === mapFlag); if (existing) existing.rooms = rooms; } stored = true; this.adapter.rLog("MapManager", this.duid, "Info", "1.0", undefined, `[updateRoomMapping] Stored room mapping for ${mapInfoFromApi.length} maps (room states only when map is loaded)`, "info"); } else { // Legacy: flat room list for current map only let result: any[] = Array.isArray(rawResult) && rawResult.length > 0 && Array.isArray(rawResult[0]) ? rawResult as any[] : []; if (result.length === 0) { const currentFloor = this.multiMaps.find(m => m.mapFlag === this.currentMapIndex); if (currentFloor && Array.isArray(currentFloor.rooms)) { result = currentFloor.rooms.map((r: any) => [r.id, r.iot_name_id, r.tag]); } } if (result.length > 0) { this.mappedRooms = result; stored = true; this.adapter.rLog("MapManager", this.duid, "Info", "1.0", undefined, `[updateRoomMapping] Stored room mapping for current floor (${result.length} rooms; states only when map loaded)`, "info"); } else { this.adapter.rLog("MapManager", this.duid, "Warn", "1.0", undefined, `[updateRoomMapping] No room mapping for Floor ${this.currentMapIndex}`, "warn"); } } // Ensure floors parent exists (single-map / empty multi-map devices may not get it from updateMultiMapsList) await this.deps.ensureFolder(`Devices.${this.duid}.floors`); await this.deps.ensureState(`Devices.${this.duid}.floors.cleanCount`, { name: "Clean count", type: "number", write: true, def: 1 }); return stored; } catch (e: any) { this.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update room mapping: ${e.message}`, "warn"); } return false; } public async updateMultiMapsList(): Promise<any[] | null> { try { // Cast result to any[] as we expect an array response const result: any[] = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_multi_maps_list", []) as any[]; let mapInfo: any[] = []; if (Array.isArray(result) && result[0] && result[0].map_info) { mapInfo = result[0].map_info; } else if (typeof result === "object" && (result as any).map_info) { mapInfo = (result as any).map_info; } this.multiMaps = mapInfo; if (mapInfo.length > 0) { await this.deps.ensureFolder(`Devices.${this.duid}.floors`); // Legacy: Global parameters from result[0] (e.g. max_multi_map) if (result[0]) { for (const key in result[0]) { if (typeof result[0][key] === "number") { await this.deps.ensureState(`Devices.${this.duid}.floors.${key}`, { name: key, type: "number", write: false }); await this.adapter.setStateChanged(`Devices.${this.duid}.floors.${key}`, { val: result[0][key], ack: true }); } } } const maps: Record<string, string> = {}; for (const map of mapInfo) { const mapFlag = map.mapFlag; const name = map.name || `Map ${mapFlag}`; const formattedTime = map.add_time ? new Date(map.add_time * 1000).toLocaleString() : "Unknown"; maps[mapFlag] = name; // Create folder for this floor (using mapFlag as stable ID) await this.deps.ensureFolder(`Devices.${this.duid}.floors.${mapFlag}`); await this.adapter.extendObject(`Devices.${this.duid}.floors.${mapFlag}`, { common: { name } }); // Create States await this.deps.ensureState(`Devices.${this.duid}.floors.${mapFlag}.name`, { name: "Floor Name", type: "string", write: false }); await this.adapter.setStateChanged(`Devices.${this.duid}.floors.${mapFlag}.name`, { val: name, ack: true }); await this.deps.ensureState(`Devices.${this.duid}.floors.${mapFlag}.mapFlag`, { name: "Map Flag", type: "number", write: false }); await this.adapter.setStateChanged(`Devices.${this.duid}.floors.${mapFlag}.mapFlag`, { val: mapFlag, ack: true }); await this.deps.ensureState(`Devices.${this.duid}.floors.${mapFlag}.add_time`, { name: "Created At", type: "string", write: false }); await this.adapter.setStateChanged(`Devices.${this.duid}.floors.${mapFlag}.add_time`, { val: formattedTime, ack: true }); // Legacy: Also keep local load button (optional but useful) await this.deps.ensureState(`Devices.${this.duid}.floors.${mapFlag}.load`, { name: "Load Map", type: "boolean", role: "button", write: true, def: false }); } // Legacy: Create load_multi_map command if (result[0] && result[0]["max_multi_map"] > 1) { await this.deps.ensureState(`Devices.${this.duid}.commands.load_multi_map`, { name: "Load map", type: "number", role: "level", write: true, def: 0, states: maps }); } return mapInfo; } return null; } catch (e: any) { this.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update floors/multi-maps: ${e.message}`, "warn"); } return null; } public updateCurrentMapIndex(mapStatus: number): boolean { this.lastMapStatus = mapStatus; if (mapStatus === undefined || mapStatus === null) return false; if (mapStatus >= 250) return false; const slotIndex = mapStatus >> 2; if (slotIndex >= 0 && slotIndex !== this.currentMapIndex) { this.currentMapIndex = slotIndex; // Use room mapping for new floor from multiMaps (if we have it) so parser can resolve names when map is loaded const floor = this.multiMaps.find((m: any) => (m.mapFlag ?? m.id) === slotIndex); this.mappedRooms = Array.isArray(floor?.rooms) ? floor.rooms.map((r: any) => [r.id, r.iot_name_id != null ? r.iot_name_id : 0]) : null; return true; } return false; } public resetCurrentMapIndex(): void { this.adapter.rLog("System", this.duid, "Info", undefined, undefined, "resetCurrentMapIndex: Forcing reset of map index to 0", "info"); this.currentMapIndex = 0; this.lastMapStatus = -1; } }