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