iobroker.roborock
Version:
501 lines (454 loc) • 21.1 kB
text/typescript
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");
}
}
}