iobroker.roborock
Version:
494 lines (434 loc) • 20 kB
text/typescript
import { FeatureDependencies } from "../../baseDeviceFeatures";
import { DeviceStateWriter } from "../../deviceStateWriter";
import { classifyB01MapPayload } from "../../../map/b01/B01MapPayloadClassifier";
import { normalizeRoborockRoomDisplayName } from "../../../roomNameNormalizer";
export class B01MapService {
private readonly stateWriter: DeviceStateWriter;
private lastQ10LiveMapBlob: Buffer | null = null;
private lastQ10PathBlob: Buffer | null = null;
private static isLiveMapPayload(data: Buffer): boolean {
return classifyB01MapPayload(data).kind === "live";
}
constructor(
private deps: FeatureDependencies,
private duid: string,
private onRoomsDetected?: (rooms: any[]) => void
) {
this.stateWriter = new DeviceStateWriter(deps, duid);
}
private getDeviceSerial(): string | null {
const device = this.deps.adapter.http_api.getDevices().find((entry: { duid: string; sn?: string }) => entry.duid === this.duid);
return typeof device?.sn === "string" && device.sn.trim() ? device.sn.trim() : null;
}
private getDefaultRoomName(): string | undefined {
return this.deps.adapter.translationManager?.get("default_room_name");
}
private translateRoomName(key: string, fallback?: string): string {
return this.deps.adapter.translationManager?.get(key, fallback) ?? fallback ?? key;
}
public async updateMap(trigger?: () => Promise<void>): Promise<void> {
const dummyId = -Math.floor(Math.random() * 1000000);
try {
// Pending request MUST exist before sending trigger, so 301 (which can arrive before 102) can find it via FIFO
this.deps.adapter.pendingRequests.set(dummyId, {
method: "get_map_v1",
duid: this.duid,
resolve: (data: unknown) => {
this.deps.adapter.pendingRequests.delete(dummyId);
if (Buffer.isBuffer(data)) {
// Guard: room/floor updates must only originate from live maps.
if (!B01MapService.isLiveMapPayload(data)) return;
void this.processUpdateMapResponse(data);
}
},
reject: () => {
this.deps.adapter.pendingRequests.delete(dummyId);
}
});
this.deps.adapter.setTimeout(() => {
if (this.deps.adapter.pendingRequests.has(dummyId)) {
this.deps.adapter.pendingRequests.delete(dummyId);
}
}, 15000);
if (trigger) {
await trigger();
} else {
// Trigger map push (Protocol 301); queue already has one entry, trigger sends and pushes "get_map_v1"
await this.deps.adapter.requestsHandler.sendRequest(this.duid, "service.upload_by_maptype", {
force: 1,
map_type: 0
});
}
} catch (e: any) {
this.deps.adapter.pendingRequests.delete(dummyId);
this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to trigger B01 map update: ${e.message}`, "warn");
}
}
public async applyLiveMapPayload(data: Buffer): Promise<void> {
await this.processUpdateMapResponse(data);
}
protected async processUpdateMapResponse(data: Buffer): Promise<void> {
const payloadClassification = classifyB01MapPayload(data);
if (payloadClassification.kind !== "live") return;
const isQ10PathOnlyPayload =
payloadClassification.variant === "q10" &&
!payloadClassification.q10?.mapData &&
!!payloadClassification.q10?.pathPoints?.length;
if (payloadClassification.variant === "q10" && payloadClassification.q10?.isLiveMapCandidate) {
const isPathOnly = isQ10PathOnlyPayload;
const previousBlob = isPathOnly ? this.lastQ10PathBlob : this.lastQ10LiveMapBlob;
if (previousBlob && previousBlob.equals(data)) {
return;
}
const snapshot = Buffer.from(data);
if (isPathOnly) {
this.lastQ10PathBlob = snapshot;
} else {
this.lastQ10LiveMapBlob = snapshot;
}
}
const serial = this.getDeviceSerial();
if (!serial) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, "Missing device serial; cannot process B01 live map.", "warn");
return;
}
// Live map only: connectionType "B01" so robot/charger are drawn; history uses B01History in getCleaningRecordMap
const mapRes = await this.deps.adapter.mapManager.processMap(
data,
"B01",
this.deps.adapter.http_api.getRobotModel(this.duid) || "B01",
serial,
null,
this.duid,
"B01"
);
if (mapRes) {
await this.processMapResults(mapRes);
// Populate mappedRooms in feature handler if we found them in the map
if (mapRes.mapData && Array.isArray(mapRes.mapData.rooms) && this.onRoomsDetected) {
this.onRoomsDetected(mapRes.mapData.rooms);
}
} else if (!isQ10PathOnlyPayload) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, "B01 Map processing returned null.", "warn");
}
}
/** Writes map result to Devices.<duid>.map (current/live map only). History maps are never passed here; they go to cleaningInfo.records.<index>.mapBase64 via getCleaningRecordMap. */
private async processMapResults(mapResult: { mapBase64: string, mapBase64Clean?: string, mapData?: any } | null): Promise<void> {
if (!mapResult) return;
const { mapData } = mapResult;
// B01 returns same image for both; use one for both if only one present so they never get out of sync
const mapBase64 = mapResult.mapBase64 || mapResult.mapBase64Clean;
const mapBase64Clean = mapResult.mapBase64Clean ?? mapResult.mapBase64;
await this.stateWriter.ensureFolder("map");
if (mapData) {
const q10MapId = Number(mapData?.q10SourceData?.mapId);
if (Number.isFinite(q10MapId) && q10MapId > 0) {
await this.stateWriter.ensureAndSetValueState(`deviceStatus.current_map_id`, {
name: "current_map_id",
type: "number",
}, q10MapId);
}
const model = this.deps.adapter.http_api.getRobotModel(this.duid);
const mapDataWithModel = { ...mapData, model: model || undefined };
await this.stateWriter.ensureAndSetValueState(`map.mapData`, { name: "Map Data", type: "string", role: "json" }, JSON.stringify(mapDataWithModel));
if (mapData.q10RuntimeDebug) {
await this.stateWriter.ensureAndSetValueState(`map.q10DebugSummary`, {
name: "Q10 Debug Summary",
type: "string",
role: "json"
}, JSON.stringify(mapData.q10RuntimeDebug));
this.deps.adapter.rLog(
"MapManager",
this.duid,
"Debug",
"B01",
301,
`[Q10Map] packet=${mapData.q10RuntimeDebug.packetKind}/${mapData.q10RuntimeDebug.payloadShape} seed=${mapData.q10RuntimeDebug.overlaySeedSource} rawWalls=${mapData.q10RuntimeDebug.rawVirtualWalls} rawForbid=${mapData.q10RuntimeDebug.rawForbidAreas} srcWalls=${mapData.q10RuntimeDebug.sourceVirtualWalls} srcForbid=${mapData.q10RuntimeDebug.sourceForbidAreas} walls=${mapData.q10RuntimeDebug.virtualWalls} forbid=${mapData.q10RuntimeDebug.forbidAreas} rooms=${mapData.q10RuntimeDebug.rooms} history=${mapData.q10RuntimeDebug.historyPoints} paths=${mapData.q10RuntimeDebug.pathPoints} obstacles=${mapData.q10RuntimeDebug.obstacles} skip=${mapData.q10RuntimeDebug.skipPoints} suspected=${mapData.q10RuntimeDebug.suspectedPoints} robot=${mapData.q10RuntimeDebug.robotPresent ? 1 : 0} charger=${mapData.q10RuntimeDebug.chargerPresent ? 1 : 0}`,
"debug"
);
}
}
if (mapBase64 || mapBase64Clean) {
await this.stateWriter.ensureState(`map.mapBase64`, { name: "Map Image", type: "string", role: "text.png" });
await this.stateWriter.ensureState(`map.mapBase64Clean`, { name: "Map Image (Clean)", type: "string", role: "text.png" });
// Write both together so a concurrent history/live update cannot leave one stale
await Promise.all([
mapBase64 ? this.stateWriter.setState(`map.mapBase64`, mapBase64) : Promise.resolve(),
mapBase64Clean ? this.stateWriter.setState(`map.mapBase64Clean`, mapBase64Clean) : Promise.resolve()
]);
}
}
protected static readonly MAPPED_CLEAN_SUMMARY: Record<string, string> = { 0: "clean_time", 1: "clean_area", 2: "clean_count", 3: "records", record_list: "records" };
/**
* B01: Load floor list via service.get_map_list and create Devices.<duid>.floors.<mapId> (name, mapFlag).
* Enables room selection for app_segment_clean.
*/
public async updateMultiMapsList(): Promise<void> {
try {
const result = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "service.get_map_list", {});
const mapList = (result && typeof result === "object" && (result as any).map_list) || (result && typeof result === "object" && (result as any).data?.map_list);
if (!Array.isArray(mapList) || mapList.length === 0) return;
await this.stateWriter.ensureFolder("floors");
for (const map of mapList) {
const mapId = map.id;
const name = map.name || `Map ${mapId}`;
const folder = `floors.${mapId}`;
await this.stateWriter.ensureFolder(folder, name);
await this.stateWriter.ensureAndSetValueState(`${folder}.name`, { name: "Floor Name", type: "string" }, name);
await this.stateWriter.ensureAndSetValueState(`${folder}.mapFlag`, { name: "Map ID", type: "number" }, mapId);
}
this.deps.adapter.rLog("System", this.duid, "Info", "B01", undefined, `B01 floors updated: ${mapList.length} maps (${mapList.map((m: any) => m.name || m.id).join(", ")})`, "info");
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `B01 updateMultiMapsList failed: ${e.message}`, "warn");
}
}
public async updateCleanSummary(): Promise<void> {
try {
const summaryRes = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "service.get_record_list", {}, { priority: -10 });
if (summaryRes) {
await this.processCleanSummary(summaryRes);
}
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `Failed to update B01 clean summary: ${e.message}`, "warn");
}
}
public async processCleanSummary(result: unknown): Promise<void> {
try {
// B01 result structure is { total_time, total_area, total_count, record_list: [ { url, detail: string }, ... ] }
let sd = result as { data?: any; total_time: number; total_area: number; total_count: number; record_list: any[] };
if (sd && sd.data) {
sd = sd.data;
}
if (!sd || !sd.record_list) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, "Invalid B01 clean summary format", "warn");
return;
}
// Update Summary States
await this.updateSummaryState("clean_time", sd.total_time);
await this.updateSummaryState("clean_area", sd.total_area);
await this.updateSummaryState("clean_count", sd.total_count);
await this.deps.ensureFolder(`Devices.${this.duid}.cleaningInfo.records`);
const recordList = sd.record_list;
const limit = 20;
recordList.sort((a: unknown, b: unknown) => {
const timeA = this.extractStartTime(a);
const timeB = this.extractStartTime(b);
return timeB - timeA;
});
// Process records one-by-one: B01 map queue has concurrency 1 (requestsHandler), and we await each
// getCleaningRecordMap so only one get_clean_record_map pending exists — avoids wrong 301 assignment.
for (let i = 0; i < Math.min(recordList.length, limit); i++) {
const recordItem = recordList[i];
let detail: any = null;
try {
detail = typeof recordItem.detail === "string" ? JSON.parse(recordItem.detail) : recordItem.detail;
} catch (e) {
this.deps.adapter.rLog("System", this.duid, "Error", "B01", undefined, `Failed to parse record detail: ${e}`, "error");
continue;
}
if (!detail) continue;
const index = i;
await this.deps.ensureFolder(`Devices.${this.duid}.cleaningInfo.records.${index}`);
await this.processRecordAttributes(index, detail);
if (detail.record_map_url || detail.url || recordItem.url) {
await this.processRecordMap(index, detail, recordItem);
}
}
const jsonSummary = {
clean_time: Number((sd.total_time / 3600).toFixed(2)),
clean_area: Number((sd.total_area / 1000000).toFixed(2)),
clean_count: sd.total_count,
records: recordList.map((r: any) => {
try {
return typeof r.detail === "string" ? JSON.parse(r.detail) : r.detail;
} catch {
return null;
}
}).filter((r: any) => r !== null)
};
await this.stateWriter.ensureAndSetValueState(`cleaningInfo.JSON`, { name: "JSON", type: "string", role: "json" }, JSON.stringify(jsonSummary));
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Error", "B01", undefined, `Error processing B01 clean summary: ${e.message}`, "error");
}
}
private async updateSummaryState(attr: string, value: number): Promise<void> {
let val = value;
if (attr === "clean_time") {
val = Number((val / 3600).toFixed(2));
} else if (attr === "clean_area") {
val = Number((val / 1000000).toFixed(2));
}
await this.stateWriter.ensureAndSetValueState(`cleaningInfo.${attr}`, {
name: attr,
type: "number",
}, val);
}
private extractStartTime(record: unknown): number {
try {
const r = record as Record<string, unknown>;
const detail = typeof r.detail === "string" ? JSON.parse(r.detail) : r.detail;
return detail.record_start_time || detail.begin || 0;
} catch {
return 0;
}
}
private async processRecordAttributes(index: number, detail: Record<string, unknown>): Promise<void> {
for (const key in detail) {
const val = detail[key];
const mappedKey = B01MapService.MAPPED_CLEAN_SUMMARY[key] || key;
const type = typeof val;
if (type !== "string" && type !== "number" && type !== "boolean") continue;
await this.stateWriter.ensureAndSetValueState(`cleaningInfo.records.${index}.${mappedKey}`, {
name: mappedKey,
type: type as ioBroker.CommonType,
role: "value",
}, val as ioBroker.StateValue);
}
}
private async processRecordMap(index: number, detail: any, recordItem: any): Promise<void> {
const startTime = detail.record_start_time || detail.begin;
const mapRes = await this.getCleaningRecordMap(startTime, recordItem, index);
if (!mapRes) return;
await this.stateWriter.ensureAndSetState(`cleaningInfo.records.${index}.mapBase64`, {
name: "Map Base64",
type: "string",
role: "text.png",
read: true,
write: false
}, mapRes.mapBase64);
}
/**
* Fetches one history map (B01). Caller must await this before starting the next record so that
* only one get_clean_record_map pending exists at a time; B01 map queue concurrency is 1 (requestsHandler).
*/
public async getCleaningRecordMap(startTime: number, recordDetails?: unknown, recordIndex?: number): Promise<{ mapBase64CleanUncropped: string; mapBase64: string; mapBase64Truncated: string; mapData: string } | null> {
try {
const details = recordDetails as { record_map_url?: string; url?: string; detail?: string | { record_map_url?: string; url?: string } };
let url = details?.record_map_url || details?.url;
if (!url && details?.detail) {
const d = typeof details.detail === "string" ? JSON.parse(details.detail) : details.detail;
url = d.record_map_url || d.url;
}
if (!url) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `No URL found in record details for B01 history map (startTime: ${startTime})`, "warn");
return null;
}
const dummyId = -Math.floor(Math.random() * 1000000);
// Store URL so raw 301 payload can be saved as record_map/<basename>.bin for offline use
const recordMapUrl = url;
const historyMapPromise = new Promise<{ mapBase64CleanUncropped: string; mapBase64: string; mapBase64Truncated: string; mapData: string } | null>((resolve) => {
this.deps.adapter.pendingRequests.set(dummyId, {
method: "get_clean_record_map",
duid: this.duid,
startTime,
recordIndex: recordIndex ?? null,
recordMapUrl,
resolve: (data: Buffer) => {
this.deps.adapter.pendingRequests.delete(dummyId);
void this.processHistoryMapResponse(data, resolve);
},
reject: () => {
this.deps.adapter.pendingRequests.delete(dummyId);
resolve(null);
}
});
this.deps.adapter.setTimeout(() => {
if (this.deps.adapter.pendingRequests.has(dummyId)) {
this.deps.adapter.pendingRequests.delete(dummyId);
resolve(null);
}
}, 30000);
});
try {
await this.deps.adapter.requestsHandler.sendRequest(this.duid, "service.upload_record_by_url", { url }, { priority: -10 });
} catch (e: any) {
this.deps.adapter.pendingRequests.delete(dummyId);
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `Failed to trigger B01 history map upload: ${e.message}`, "warn");
return null;
}
return historyMapPromise;
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to get cleaning record map (B01): ${e.message}`, "warn");
return null;
}
}
/** History map: only resolve with result; do NOT write to Devices.xxx.map. Caller saves to cleaningInfo.records.<index>.mapBase64. */
private async processHistoryMapResponse(data: unknown, resolve: (val: any) => void): Promise<void> {
try {
if (!Buffer.isBuffer(data)) {
resolve(null);
return;
}
const serial = this.getDeviceSerial();
if (!serial) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, "Missing device serial; cannot process B01 history map.", "warn");
resolve(null);
return;
}
const mapRes = await this.deps.adapter.mapManager.processMap(
data,
"B01",
this.deps.adapter.http_api.getRobotModel(this.duid) || "B01",
serial,
null,
this.duid,
"B01History"
);
resolve(mapRes);
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Error", "B01", undefined, `Failed to process history map: ${e.message}`, "error");
resolve(null);
}
}
public async updateRoomMapping(mappedRooms?: any[], options: { refreshFloors?: boolean } = {}): Promise<void> {
if (!mappedRooms) return;
// Floors and names come only from the API; refresh so current floor folder exists with correct name (issue #1140)
if (options.refreshFloors ?? true) {
await this.updateMultiMapsList();
}
// B01: use current_map_id (map id like 1770281900); map_status is a small status code
const currentMapIdState = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.deviceStatus.current_map_id`);
let floorID = 0;
if (currentMapIdState && typeof currentMapIdState.val === "number" && currentMapIdState.val > 0) {
floorID = currentMapIdState.val;
} else {
const mapStatusState = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.deviceStatus.map_status`);
if (mapStatusState && typeof mapStatusState.val === "number") {
floorID = mapStatusState.val;
}
}
const floorFolder = `floors.${floorID}`;
// Guarantee folder exists before writing room states (updateMultiMapsList may have failed, returned empty, or floorID may not be in API list)
await this.stateWriter.ensureFolder(floorFolder);
// We assume mappedRooms is [{ id: 10, name: "Living Room" }, ...] or [{ roomId: 10, roomName: "Living Room" }, ...]
const rooms = mappedRooms || [];
for (const room of rooms) {
const roomID = room?.id ?? room?.roomId;
if (typeof roomID !== "number") {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `Invalid room data in mappedRooms: ${JSON.stringify(room)}`, "warn");
continue;
}
const rawRoomName =
typeof room.name === "string" && room.name.trim()
? room.name
: typeof room.roomName === "string"
? room.roomName
: "";
const roomName = normalizeRoborockRoomDisplayName(
rawRoomName,
() => this.getDefaultRoomName(),
(key, fallback) => this.translateRoomName(key, fallback),
typeof room?.roomTypeId === "number" ? room.roomTypeId : undefined
);
const roomStateId = `${floorFolder}.${roomID}`;
await this.stateWriter.ensureState(roomStateId, {
name: roomName,
type: "boolean",
role: "value",
def: false,
read: true,
write: true
});
}
}
}