UNPKG

iobroker.roborock

Version:
350 lines (304 loc) 13 kB
import type { Canvas, CanvasRenderingContext2D } from "@napi-rs/canvas"; import { createCanvas, Image, loadImage } from "@napi-rs/canvas"; import { drawMapV1 } from "../../../common/mapDrawing/drawMapV1"; import { hexToRgbaString, VISUAL_BLOCK_SIZE } from "../../../common/mapDrawing/constants"; import * as Images from "../../../common/images"; import { Roborock } from "../../../main"; import { assignRoborockRoomColorsToHex } from "../../roomColoring"; import { LEGACY_COLORS, ROBOROCK_PALETTE } from "../MapHelper"; // Do not use "import { X, type Y }" — ioBroker runtime (esbuild-register) does not support inline type in named imports import { CanvasMapRenderer } from "./CanvasMapRenderer"; const OFFSET = 60; const MAX_BLOCK_NUM = 32; const ORG_COLORS = ROBOROCK_PALETTE; interface CanvasMapOptions { selectedMap?: any; mappedRooms?: any; model?: string; duid?: string; options?: { FLOORCOLOR?: string; WALLCOLOR?: string; PATHCOLOR?: string; newmap?: boolean; ROBOT?: string; }; } // Define a custom interface to handle missing type definitions in @napi-rs/canvas interface ExtendedContext2D extends CanvasRenderingContext2D { drawImage(image: Image | Canvas, dx: number, dy: number, dw?: number, dh?: number): void; canvas: Canvas; antialias?: string; } // ----------------------------------------------------------------------------- // Map Creator Class // ----------------------------------------------------------------------------- export class MapBuilder { adapter: Roborock; colors: { floor: string; obstacle: string; path: string; newmap: boolean }; constructor(adapter: Roborock) { this.adapter = adapter; this.colors = { floor: LEGACY_COLORS.floor, obstacle: LEGACY_COLORS.obstacle, path: LEGACY_COLORS.path, newmap: true, }; } // -------------------- // Segment color (for drawMapV1 getSegmentColor callback) // -------------------- private buildGetSegmentColor(mapdata: any, currentlyCleanedBlocks: number[]): (segmentId: number) => string | undefined { const image = mapdata.IMAGE; if (!image.pixels?.segments?.length) return () => undefined; const segmentsData: Record<number, { count: number }> = {}; image.pixels.segments.forEach((px: number) => { const segnum = px >>> 21; if (segnum >= MAX_BLOCK_NUM) return; if (!segmentsData[segnum]) segmentsData[segnum] = { count: 0 }; segmentsData[segnum].count++; }); const segmentNums = Object.keys(segmentsData).map(Number); const maxId = segmentNums.length ? Math.max(...segmentNums) : 0; const matrixSize = MAX_BLOCK_NUM; const adjacencyMatrix = this.buildAdjacencyMatrix(image.pixels.segments, image.dimensions.width, image.dimensions.height, maxId); const pointsCount = new Array(matrixSize).fill(0); for (const segStr of segmentNums) { if (segStr >= 0 && segStr < matrixSize) pointsCount[segStr] = segmentsData[segStr].count; } const neighborInfo = new Array(matrixSize * matrixSize).fill(0); for (let i = 0; i < matrixSize; i++) { for (let j = 0; j < matrixSize; j++) { if (adjacencyMatrix[i]?.[j] === 1) neighborInfo[i * matrixSize + j] = 1; } if (pointsCount[i] > 0) neighborInfo[i * matrixSize + i] = 1; } const coloring = this.colors.newmap ? assignRoborockRoomColorsToHex({ maxBlockNum: matrixSize, neighborInfo, pointsCount }, { oneBased: true }) : { getColor: () => "#CCCCCC" }; const segColorHex: Record<number, string> = {}; for (let i = 0; i < matrixSize; i++) { if (pointsCount[i] > 0) { if (this.colors.newmap) { let theme = this.adapter.config.map_theme; if (theme !== "light") theme = "dark"; const isCleanMode = currentlyCleanedBlocks?.length > 0; const isCurrentlyCleaned = isCleanMode && currentlyCleanedBlocks?.includes(i); const paletteType: "light_normal" | "light_highlight" | "dark_normal" | "dark_highlight" = isCleanMode ? (isCurrentlyCleaned ? (theme === "dark" ? "dark_highlight" : "light_highlight") : theme === "dark" ? "dark_normal" : "light_normal") : theme === "dark" ? "dark_highlight" : "light_highlight"; segColorHex[i] = coloring.getColor(i, paletteType); } else { if (currentlyCleanedBlocks?.includes(i)) { segColorHex[i] = i >= 0 && i < ORG_COLORS.length ? (ORG_COLORS[i] || "#CCCCCC") : "#CCCCCC"; } } } } return (segmentId: number) => { const hex = segColorHex[segmentId]; return hex ? hexToRgbaString(hex) : undefined; }; } private buildAdjacencyMatrix(segmentPixels: number[], width: number, height: number, maxIdFromCaller: number): number[][] { let maxSegInPixels = 0; for (const px of segmentPixels) { const segnum = px >>> 21; if (segnum > maxSegInPixels) maxSegInPixels = segnum; } const size = Math.max(maxIdFromCaller + 1, maxSegInPixels + 1, MAX_BLOCK_NUM); const matrix: number[][] = Array.from({ length: size }, () => Array(size).fill(0)); const segMap = new Int16Array(width * height).fill(-1); for (const px of segmentPixels) { const pixelIndex = px & 0x1fffff; const segnum = px >>> 21; if (pixelIndex >= 0 && pixelIndex < segMap.length && segnum < size) { segMap[pixelIndex] = segnum; } } for (let i = 0; i < segMap.length; i++) { const segA = segMap[i]; if (segA < 0) continue; const x = i % width; const y = Math.floor(i / width); if (segA < size) matrix[segA][segA] = 1; if (y > 0) { const segB = segMap[i - width]; if (segB >= 0 && segA !== segB && segA < size && segB < size) { matrix[segA][segB] = 1; matrix[segB][segA] = 1; } } if (x > 0) { const segB = segMap[i - 1]; if (segB >= 0 && segA !== segB && segA < size && segB < size) { matrix[segA][segB] = 1; matrix[segB][segA] = 1; } } } return matrix; } // -------------------- // Main Map Generation (single source: drawMapV1 + CanvasMapRenderer) // -------------------- public async canvasMap(mapdata: any, params: CanvasMapOptions = {}): Promise<[string, string, string]> { const { mappedRooms = null, options = {} } = params; if (!mapdata || !mapdata.IMAGE || !mapdata.IMAGE.dimensions) { this.adapter.rLog("MapManager", params.model || null, "Warn", undefined, undefined, "Received invalid or empty map data, cannot generate map.", "warn"); const errorCanvas = createCanvas(1, 1).toDataURL(); return [errorCanvas, errorCanvas, errorCanvas]; } this.applyOptions(options); const [imgRobot, imgCharger, imgGoToPin] = await this.loadImages(options.ROBOT); // Grid dimensions (unchanged in mapdata for web UI) const gridW = mapdata.IMAGE.dimensions.width; const gridH = mapdata.IMAGE.dimensions.height; const canvasWidth = gridW * VISUAL_BLOCK_SIZE; const canvasHeight = gridH * VISUAL_BLOCK_SIZE; const canvas = createCanvas(canvasWidth, canvasHeight); const ctx = canvas.getContext("2d") as unknown as ExtendedContext2D; ctx.imageSmoothingEnabled = false; if ("antialias" in ctx) ctx.antialias = "none"; const getSegmentColor = this.buildGetSegmentColor(mapdata, mapdata.CURRENTLY_CLEANED_BLOCKS || []); const roomNames = await this.buildRoomNamesMap(mappedRooms, params.duid); const renderer = new CanvasMapRenderer({ ctx: ctx as any, robotImage: imgRobot, chargerImage: imgCharger, goToPinImage: imgGoToPin, loadObstacleImage: (suffix, model) => this.loadObstacleImageForRenderer(suffix, model), model: params.model, logWarn: (msg) => this.adapter.rLog("MapManager", params.model || null, "Warn", undefined, undefined, msg, "debug"), }); const t0 = Date.now(); const result = await drawMapV1(mapdata, renderer, { scaleFactor: VISUAL_BLOCK_SIZE, dimensionsAreScaled: false, getSegmentColor, roomNames, }); const t1 = Date.now(); const cleanMapUncroppedBase64 = renderer.getCleanSnapshot() ?? canvas.toDataURL(); const fullMapUncroppedBase64 = canvas.toDataURL(); const bounds = result.bounds ? { minleft: result.bounds.minX, mintop: result.bounds.minY, maxleft: result.bounds.maxX, maxtop: result.bounds.maxY } : { minleft: 0, mintop: 0, maxleft: canvasWidth, maxtop: canvasHeight }; const croppedMapBase64 = this.cropMap(canvas, ctx, bounds); if (t1 - t0 > 1000) { this.adapter.rLog("MapManager", params.model || null, "Warn", "MapProfiler", undefined, `[Slow Map] drawMapV1: ${t1 - t0}ms`, "debug"); } return [cleanMapUncroppedBase64, fullMapUncroppedBase64, croppedMapBase64]; } private async buildRoomNamesMap(mappedRooms: any, duid?: string): Promise<Map<number, string> | undefined> { if (!mappedRooms || !Array.isArray(mappedRooms) || !this.adapter?.http_api) return undefined; const roomIDsAll = duid && this.adapter.http_api.isSharedDevice(duid) ? await this.adapter.http_api.getSharedDeviceRooms(duid) : this.adapter.http_api.getMatchedRoomIDs(false); const map = new Map<number, string>(); for (const mapping of mappedRooms) { const segIdStr = mapping[0]; const roomID = mapping[1]; const segnum = parseInt(String(segIdStr), 10); const roomObj = roomIDsAll.find((r: any) => String(r.id) === String(roomID)); if (roomObj?.name) map.set(segnum, roomObj.name); } return map.size ? map : undefined; } private async loadObstacleImageForRenderer(suffix: string, model?: string): Promise<import("@napi-rs/canvas").Image | null> { const imagePath = await this.findObstacleImage(suffix, model); if (!imagePath) return null; return this.loadImageFromAdapterPath(imagePath); } private applyOptions(options: any) { if (options) { if (options.FLOORCOLOR) this.colors.floor = options.FLOORCOLOR; if (options.WALLCOLOR) this.colors.obstacle = options.WALLCOLOR; if (options.PATHCOLOR) this.colors.path = options.PATHCOLOR; this.colors.newmap = options.newmap ?? true; } } private async loadImages(robotType?: string) { let robotImgSource = Images.IMG_ROBOT_ORIGINAL; switch (robotType) { case "robot": robotImgSource = Images.IMG_ROBOT_DEFAULT; break; case "robot1": robotImgSource = Images.IMG_ROBOT1; break; case "tank": robotImgSource = Images.IMG_TANK; break; case "spaceship": robotImgSource = Images.IMG_SPACESHIP; break; case "robot2": robotImgSource = Images.IMG_ROBOT_2; break; } return Promise.all([loadImage(robotImgSource), loadImage(Images.IMG_CHARGER), loadImage(Images.IMG_GO_TO_PIN)]); } private static obstacleImageCache = new Map<string, string | null>(); private async findObstacleImage(suffix: string, model?: string): Promise<string | null> { const cacheKey = `${model || "default"}_${suffix}`; if (MapBuilder.obstacleImageCache.has(cacheKey)) { return MapBuilder.obstacleImageCache.get(cacheKey)!; } if (!this.adapter?.name) return null; const assetModels = [model, "roborock.vacuum.a147"].filter((m): m is string => !!m); const fileNames = [ `projects_comroborocktanos_resources_obstacle_new_p${suffix}.png`, `projects_comroborocktanos_resources_map_object_top_${suffix}.png`, ]; let foundPath: string | null = null; for (const fileName of fileNames) { for (const m of assetModels) { const potentialPaths = [ `assets/${m}/drawable-mdpi/${fileName}`, `assets/${m}/drawable-hdpi/${fileName}`, `images/${fileName}`, ]; for (const imagePath of potentialPaths) { if (await this.adapter.fileExistsAsync(this.adapter.name, imagePath)) { foundPath = imagePath; break; } } if (foundPath) break; } if (foundPath) break; } MapBuilder.obstacleImageCache.set(cacheKey, foundPath); return foundPath; } private async loadImageFromAdapterPath(adapterPath: string): Promise<Image | null> { if (!this.adapter?.name) return null; try { const res = await this.adapter.readFileAsync(this.adapter.name, adapterPath); const buf = typeof res === "object" && res !== null && "file" in res ? (res as { file: Buffer }).file : Buffer.from(res as ArrayBuffer); return await loadImage(buf); } catch { return null; } } private cropMap(canvas: Canvas, ctx: ExtendedContext2D, bounds: { minleft: number; mintop: number; maxleft: number; maxtop: number }) { const { minleft, mintop, maxleft, maxtop } = bounds; const cropW = maxleft - minleft + 2 * OFFSET; const cropH = maxtop - mintop + 2 * OFFSET; if (cropW <= 0 || cropH <= 0 || !isFinite(cropW) || !isFinite(cropH)) { return canvas.toDataURL(); } const sx = Math.max(0, minleft - OFFSET); const sy = Math.max(0, mintop - OFFSET); const maxWidth = canvas.width - sx; const maxHeight = canvas.height - sy; const finalCropW = Math.min(cropW, maxWidth); const finalCropH = Math.min(cropH, maxHeight); const canvasTrimmedFull = createCanvas(finalCropW, finalCropH); const ctxTrimmedFull = canvasTrimmedFull.getContext("2d"); const trimmedDataFull = ctx.getImageData(sx, sy, finalCropW, finalCropH); ctxTrimmedFull.putImageData(trimmedDataFull, 0, 0); return canvasTrimmedFull.toDataURL(); } }