UNPKG

iobroker.roborock

Version:
410 lines (355 loc) 13.8 kB
import type { FeatureDependencies } from "../../../baseDeviceFeatures"; import { DeviceStateWriter } from "../../../deviceStateWriter"; type Q10CleanRecordServiceDeps = { deps: FeatureDependencies; duid: string; robotModel: string; }; export class Q10CleanRecordService { private q10RecordIndexById = new Map<string, number>(); private q10RecordSelectTokenById = new Map<string, string>(); private q10PendingRecordMapRequests = new Set<string>(); private q10QueuedRecordMapRequests: string[] = []; private q10ActiveRecordMapRequest: { recordId: string; index: number; acknowledged: boolean } | null = null; private q10RecordMapRequestTimeout: ioBroker.Timeout | undefined; private static readonly Q10_RECORD_MAP_TIMEOUT_MS = 15000; private readonly stateWriter: DeviceStateWriter; constructor(private readonly featureDeps: Q10CleanRecordServiceDeps) { this.stateWriter = new DeviceStateWriter(this.deps, this.duid); } private get deps(): FeatureDependencies { return this.featureDeps.deps; } private get duid(): string { return this.featureDeps.duid; } private get robotModel(): string { return this.featureDeps.robotModel; } private formatQ10Time(value: number): string { return value.toString().padStart(2, "0"); } private async deleteObjectIfExists(relativePath: string, recursive = false): Promise<void> { const id = this.stateWriter.path(relativePath); const existing = await this.deps.adapter.getObjectAsync(id); if (!existing) return; await this.deps.adapter.delObjectAsync(id, recursive ? { recursive: true } : undefined); } private decodeQ10RecordMapPayload(value: unknown): Buffer | null { if (Buffer.isBuffer(value)) return value; if (value instanceof Uint8Array) return Buffer.from(value); if (value instanceof ArrayBuffer) return Buffer.from(value); if (typeof value === "string") { const payload = value.trim(); if (!payload) return null; if (/^[0-9a-f]+$/i.test(payload) && payload.length % 2 === 0) { const hex = Buffer.from(payload, "hex"); if (hex.length > 0) return hex; } if (/^[A-Za-z0-9+/=]+$/.test(payload) && payload.length >= 8) { const base64 = Buffer.from(payload, "base64"); if (base64.length > 0) return base64; } return null; } if (!value || typeof value !== "object") return null; for (const key of ["data", "map", "payload", "detail", "blob", "raw"]) { const nested = (value as Record<string, unknown>)[key]; const decoded = this.decodeQ10RecordMapPayload(nested); if (decoded) return decoded; } return null; } private async ensureQ10RecordMapStates(index: number, mapRes: { mapBase64: string; mapBase64Clean?: string; mapData?: unknown }): Promise<void> { const folder = `cleaningInfo.records.${index}`; const mapFolder = `${folder}.map`; await this.stateWriter.ensureFolder(folder); await this.stateWriter.ensureFolder(mapFolder); await this.deleteObjectIfExists(`${folder}.mapBase64`); await this.deleteObjectIfExists(`${folder}.mapBase64Clean`); await this.deleteObjectIfExists(`${folder}.mapData`); await this.deleteObjectIfExists(`${mapFolder}.mapBase64Clean`); await this.stateWriter.ensureAndSetState(`${mapFolder}.mapBase64`, { name: "Map Base64", type: "string", role: "text.png", read: true, write: false }, mapRes.mapBase64); if (mapRes.mapData) { await this.stateWriter.ensureAndSetValueState(`${mapFolder}.mapData`, { name: "Map Data", type: "string", role: "json" }, JSON.stringify(mapRes.mapData)); } } private clearQ10RecordMapTimeout(): void { if (this.q10RecordMapRequestTimeout) { this.deps.adapter.clearTimeout(this.q10RecordMapRequestTimeout); this.q10RecordMapRequestTimeout = undefined; } } private formatQ10StoredMapOverlaySummary(mapData: unknown): string { if (!mapData || typeof mapData !== "object" || Array.isArray(mapData)) return ""; const mapDataRecord = mapData as Record<string, unknown>; const runtimeDebug = mapDataRecord.q10RuntimeDebug && typeof mapDataRecord.q10RuntimeDebug === "object" && !Array.isArray(mapDataRecord.q10RuntimeDebug) ? mapDataRecord.q10RuntimeDebug as Record<string, unknown> : null; if (!runtimeDebug) return ""; return ` | seed=${String(runtimeDebug.overlaySeedSource ?? "none")} rawWalls=${Number(runtimeDebug.rawVirtualWalls ?? 0)} rawForbid=${Number(runtimeDebug.rawForbidAreas ?? 0)} srcWalls=${Number(runtimeDebug.sourceVirtualWalls ?? 0)} srcForbid=${Number(runtimeDebug.sourceForbidAreas ?? 0)} walls=${Number(runtimeDebug.virtualWalls ?? 0)} forbid=${Number(runtimeDebug.forbidAreas ?? 0)}`; } private async finishQ10CleanRecordMapRequest(recordId: string, success: boolean): Promise<void> { if (this.q10ActiveRecordMapRequest?.recordId !== recordId) { return; } this.clearQ10RecordMapTimeout(); this.q10ActiveRecordMapRequest = null; this.q10PendingRecordMapRequests.delete(recordId); if (!success) { this.q10QueuedRecordMapRequests = this.q10QueuedRecordMapRequests.filter((queuedId) => queuedId !== recordId); } await this.requestNextQ10CleanRecordMap(); } private async requestNextQ10CleanRecordMap(): Promise<void> { if (this.q10ActiveRecordMapRequest || this.q10QueuedRecordMapRequests.length === 0) { return; } while (this.q10QueuedRecordMapRequests.length > 0) { const recordId = this.q10QueuedRecordMapRequests.shift(); if (!recordId) return; const index = this.q10RecordIndexById.get(recordId); if (index == null) { this.q10PendingRecordMapRequests.delete(recordId); continue; } const selectToken = this.q10RecordSelectTokenById.get(recordId); if (!selectToken) { this.q10PendingRecordMapRequests.delete(recordId); this.deps.adapter.rLog( "System", this.duid, "Warn", "B01", 52, `Q10 clean record detail ${recordId} missing select token from 52.list entry.`, "warn" ); continue; } this.q10ActiveRecordMapRequest = { recordId, index, acknowledged: false }; this.clearQ10RecordMapTimeout(); this.q10RecordMapRequestTimeout = this.deps.adapter.setTimeout(() => { const active = this.q10ActiveRecordMapRequest; if (!active || active.recordId !== recordId) return; this.deps.adapter.rLog( "System", this.duid, "Warn", "B01", 52, `Q10 clean record detail ${recordId} timed out waiting for blob type 3.`, "warn" ); void this.finishQ10CleanRecordMapRequest(recordId, false); }, Q10CleanRecordService.Q10_RECORD_MAP_TIMEOUT_MS); try { await this.deps.adapter.requestsHandler.publishB01Dp(this.duid, { "101": { "52": { op: "select", id: selectToken } } }); return; } catch (e: unknown) { this.clearQ10RecordMapTimeout(); this.q10ActiveRecordMapRequest = null; this.q10PendingRecordMapRequests.delete(recordId); this.deps.adapter.rLog( "System", this.duid, "Warn", "B01", undefined, `Q10 request clean record detail ${recordId}: ${this.deps.adapter.errorMessage(e)}`, "warn" ); } } } private async requestMissingQ10CleanRecordMaps( records: ReadonlyArray<{ raw: string; record_id: string; map_len: number; path_len: number; virtual_len: number }> ): Promise<void> { let queuedCount = 0; for (const record of records) { const shouldHaveMap = record.map_len > 0 || record.path_len > 0 || record.virtual_len > 0; if (!shouldHaveMap || !record.record_id) continue; if (this.q10PendingRecordMapRequests.has(record.record_id)) continue; const index = this.q10RecordIndexById.get(record.record_id); if (index == null) continue; const existingMap = await this.deps.adapter.getStateAsync(this.stateWriter.path(`cleaningInfo.records.${index}.map.mapBase64`)) ?? await this.deps.adapter.getStateAsync(this.stateWriter.path(`cleaningInfo.records.${index}.mapBase64`)); if (typeof existingMap?.val === "string" && existingMap.val.startsWith("data:image/")) continue; this.q10PendingRecordMapRequests.add(record.record_id); if (!this.q10QueuedRecordMapRequests.includes(record.record_id)) { this.q10QueuedRecordMapRequests.push(record.record_id); queuedCount++; } } if (queuedCount > 0) { this.deps.adapter.rLog( "System", this.duid, "Debug", "B01", 52, `Q10 queued ${queuedCount} clean record map request(s).`, "debug" ); } await this.requestNextQ10CleanRecordMap(); } public hasPendingQ10CleanRecordBlobRequest(): boolean { return this.q10ActiveRecordMapRequest !== null; } public async applyQ10CleanRecordBlob(blobPayload: Buffer): Promise<boolean> { if (!this.q10ActiveRecordMapRequest) return false; if (blobPayload[0] !== 3) return false; const { recordId, index } = this.q10ActiveRecordMapRequest; try { const device = this.deps.adapter.http_api.getDevices().find((entry: { duid: string }) => entry.duid === this.duid); const model = this.deps.adapter.http_api.getRobotModel(this.duid) || this.robotModel; const serial = typeof device?.sn === "string" && device.sn.trim() ? device.sn.trim() : null; if (!serial) { this.deps.adapter.rLog( "System", this.duid, "Warn", "B01", 52, `Q10 clean record blob ${recordId} skipped because the device serial is missing.`, "warn" ); await this.finishQ10CleanRecordMapRequest(recordId, false); return true; } const mapRes = await this.deps.adapter.mapManager.processMap( blobPayload, "B01", model, serial, null, this.duid, "B01History" ); if (!mapRes?.mapBase64) return true; await this.ensureQ10RecordMapStates(index, mapRes); this.deps.adapter.rLog( "MapManager", this.duid, "Debug", "B01", 52, `Q10 clean record map stored for record ${recordId} at index ${index}${this.formatQ10StoredMapOverlaySummary(mapRes.mapData)}`, "debug" ); await this.finishQ10CleanRecordMapRequest(recordId, true); return true; } catch { return true; } } private async applyQ10CleanRecordDetail(dp52: Record<string, unknown>): Promise<void> { const op = String(dp52.op ?? ""); if (op !== "select") return; const activeRequest = this.q10ActiveRecordMapRequest; const recordId = activeRequest?.recordId ?? String(dp52.id ?? dp52.record_id ?? ""); if (!recordId) return; const result = Number(dp52.result ?? 0); if (result !== 1) { this.deps.adapter.rLog("System", this.duid, "Warn", "B01", 52, `Q10 clean record detail ${recordId} select failed with result=${result}.`, "warn"); await this.finishQ10CleanRecordMapRequest(recordId, false); return; } const payload = this.decodeQ10RecordMapPayload(dp52.data ?? dp52.payload ?? dp52); if (payload?.length) { await this.applyQ10CleanRecordBlob(payload); return; } if (activeRequest && activeRequest.recordId === recordId && activeRequest.acknowledged) { return; } if (activeRequest && activeRequest.recordId === recordId) { activeRequest.acknowledged = true; } } public async applyQ10CleanRecordList(dp52: Record<string, unknown>): Promise<void> { const op = dp52.op; const result = dp52.result; if (op === "select") { await this.applyQ10CleanRecordDetail(dp52); return; } if (op !== "list" || result !== 1 || !Array.isArray(dp52.data)) return; try { const records = dp52.data .filter((entry): entry is string => typeof entry === "string" && entry.includes("_")) .map((entry) => { const parts = entry.split("_"); if (parts.length < 12) return null; const timestamp = Number(parts[1] ?? 0); const date = new Date(timestamp * 1000); const begin = `${this.formatQ10Time(date.getMonth() + 1)}/${this.formatQ10Time(date.getDate())} ${this.formatQ10Time(date.getHours())}:${this.formatQ10Time(date.getMinutes())}`; return { raw: entry, record_id: parts[0] ?? "", timestamp, begin, clean_time: Number(parts[2] ?? 0), clean_area: Number(parts[3] ?? 0), map_len: Number(parts[4] ?? 0), path_len: Number(parts[5] ?? 0), virtual_len: Number(parts[6] ?? 0), clean_mode: Number(parts[7] ?? 0), work_mode: Number(parts[8] ?? 0), cleaning_result: Number(parts[9] ?? 0), start_method: Number(parts[10] ?? 0), dust_collection_count: Number(parts[11] ?? 0) }; }) .filter((entry): entry is NonNullable<typeof entry> => entry !== null) .sort((left, right) => right.timestamp - left.timestamp); this.q10RecordIndexById.clear(); this.q10RecordSelectTokenById.clear(); await this.stateWriter.ensureFolder("cleaningInfo"); await this.stateWriter.ensureFolder("cleaningInfo.records"); await this.stateWriter.ensureAndSetValueState(`cleaningInfo.record_count`, { name: "Record Count", type: "number" }, records.length); for (let index = 0; index < records.length; index++) { const record = records[index]; const folder = `cleaningInfo.records.${index}`; this.q10RecordIndexById.set(record.record_id, index); this.q10RecordSelectTokenById.set(record.record_id, record.raw); await this.stateWriter.ensureFolder(folder, record.begin); for (const [key, value] of Object.entries(record)) { await this.stateWriter.ensureAndSetValueState(`${folder}.${key}`, { name: key, type: typeof value === "number" ? "number" : "string", role: typeof value === "number" ? "value" : "text" }, value); } } await this.requestMissingQ10CleanRecordMaps(records); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `Q10 applyQ10CleanRecordList: ${this.deps.adapter.errorMessage(e)}`, "warn"); } } }