UNPKG

iobroker.roborock

Version:
949 lines (831 loc) 35.1 kB
import * as crypto from "node:crypto"; import * as mqtt from "mqtt"; import * as protobuf from "protobufjs"; import * as zlib from "node:zlib"; import type { Roborock } from "../main"; import { Q10DpDispatcher } from "./b01/q10/Q10DpDispatcher"; import { classifyB01MapPayload } from "./map/b01/B01MapPayloadClassifier"; import { MapDecryptor as B01MapDecryptor } from "./map/b01/MapDecryptor"; import { ROBOROCK_PROTO_STR } from "./map/b01/roborock_proto"; import { B01ChunkAssembler } from "./B01ChunkAssembler"; import { PhotoManager } from "./PhotoManager"; let cachedB01RobotMapType: protobuf.Type | null = null; export class mqtt_api { adapter: Roborock; mqttUser: string; mqttPassword: string; client: any; connected: boolean; public photoManager: PhotoManager; private chunkAssembler: B01ChunkAssembler; private readonly q10DpDispatcher: Q10DpDispatcher; mqttOptions: any; reconnectTimer: any; constructor(adapter: Roborock) { this.adapter = adapter; this.mqttUser = ""; this.mqttPassword = ""; this.client = null; this.connected = false; this.mqttOptions = null; this.reconnectTimer = null; this.photoManager = new PhotoManager(this.adapter); this.chunkAssembler = new B01ChunkAssembler(adapter); this.q10DpDispatcher = new Q10DpDispatcher(adapter); } /** * Initializes the MQTT API by setting up credentials and connecting. */ async init(): Promise<void> { this.setup_mqtt_user(); await this.connect_mqtt(); } /** * Derives MQTT credentials from the RRIOT data. */ setup_mqtt_user(): void { const rriot = this.adapter.http_api.get_rriot(); // Generate MQTT username and password based on rriot data hashing this.mqttUser = this.md5hex(rriot.u + ":" + rriot.k).substring(2, 10); this.mqttPassword = this.md5hex(rriot.s + ":" + rriot.k).substring(16); this.mqttOptions = { clientId: this.mqttUser, username: this.mqttUser, password: this.mqttPassword, keepalive: 30, reconnectPeriod: 60000, // reconnect every 60s if disconnected clean: true, }; } /** * Establishes the connection to the Roborock MQTT broker. */ async connect_mqtt(): Promise<void> { if (!this.mqttOptions) { throw new Error("MQTT options not initialized. Call setup_mqtt_user() first."); } const rriot = this.adapter.http_api.get_rriot(); this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `Connecting to MQTT Broker at ${rriot.r.m}...`, "info"); const client = mqtt.connect(rriot.r.m, this.mqttOptions); this.client = client; // Robust global error handling to prevent uncaught exceptions (like EPIPE) client.on("error", (err: Error) => { this.adapter.rLog("MQTT", null, "Error", "Info", undefined, `MQTT Client Error: ${err.message}`, "error"); this.connected = false; }); // Set up persistent listeners early this.subscribe_mqtt_message(client); return new Promise((resolve, reject) => { let connectionHandled = false; const onConnect = () => { if (!connectionHandled) { connectionHandled = true; this.connected = true; // Now set up event listeners and WAIT for initial subscription this.subscribe_mqtt_events(client) .then(() => resolve()) .catch((err) => reject(err)); } }; if (client.connected) { onConnect(); } else { client.once("connect", onConnect); } client.once("error", (error: Error) => { if (!connectionHandled) { connectionHandled = true; this.adapter.rLog("MQTT", null, "Error", "Info", undefined, `MQTT connection setup failed. Error: ${error.message}`, "error"); this.connected = false; client.end(); reject(error); } }); // Timeout after 10s to prevent infinite wait this.adapter.setTimeout(() => { if (!connectionHandled) { connectionHandled = true; client.end(); reject(new Error("MQTT connection timeout after 10s")); } }, 10000); }); } /** * Subscribes to MQTT client events (connect, error, offline, etc.). * @param client - The MQTT client instance. */ async subscribe_mqtt_events(client: any): Promise<void> { const rriot = this.adapter.http_api.get_rriot(); return new Promise((resolveSubscription, rejectSubscription) => { let initialSubscriptionHandled = false; const doSubscribe = () => { this.connected = true; this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `MQTT connection established. subscribing...`, "info"); const topic = `rr/m/o/${rriot.u}/${this.mqttUser}/#`; client.subscribe(topic, (err: Error | null) => { if (err) { this.adapter.rLog("MQTT", null, "Error", "Info", undefined, `Failed to subscribe to ${topic}! Error: ${err}`, "error"); if (!initialSubscriptionHandled) { initialSubscriptionHandled = true; rejectSubscription(err); } } else { this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `Subscribed to ${topic}`, "debug"); if (!initialSubscriptionHandled) { initialSubscriptionHandled = true; resolveSubscription(); } } }); }; client.on("connect", doSubscribe); if (client.connected) { doSubscribe(); } // Safety timeout for subscription this.adapter.setTimeout(() => { if (!initialSubscriptionHandled) { initialSubscriptionHandled = true; this.adapter.rLog("MQTT", null, "Warn", "MQTT", undefined, `Initial subscription timed out, continuing anyway.`, "warn"); resolveSubscription(); // Don't block forever if sub fails but conn is up } }, 5000); }); client.on("disconnect", () => { this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `MQTT disconnected.`, "info"); this.connected = false; }); client.on("error", (error: Error) => { this.adapter.rLog("MQTT", null, "Error", "Info", undefined, `MQTT connection error: ${error.message}. Broker: ${rriot.r.m}`, "error"); this.connected = false; }); client.on("close", () => { this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `MQTT connection closed. Reconnecting in 60 seconds...`, "info"); this.connected = false; }); client.on("reconnect", () => { this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `MQTT attempting to reconnect...`, "info"); // Subscription is usually handled automatically by MQTT client on reconnect if clean=false, // but if we need to re-subscribe manually: const topic = `rr/m/o/${rriot.u}/${this.mqttUser}/#`; client.subscribe(topic, (err: Error | null) => { if (err) this.adapter.rLog("MQTT", null, "Error", "MQTT", undefined, `Failed to re-subscribe during reconnect: ${err}`, "error"); }); }); client.on("offline", () => { this.adapter.rLog("MQTT", null, "Info", undefined, undefined, "MQTT connection went offline.", "warn"); this.connected = false; }); } /** * Sets up the listener for incoming MQTT messages. * @param client - The MQTT client instance. */ async subscribe_mqtt_message(client: any): Promise<void> { client.on("message", async (topic: string, message: Buffer) => { try { /** * Listens for incoming MQTT messages and dispatches them to the appropriate handlers. * @see test/unit/transport_specification.test.ts for the MQTT topic structure. */ const parts = topic.split("/"); const duid = parts[parts.length - 1]; // ALWAYS use the last segment as the DUID if (!duid) { this.adapter.rLog("MQTT", null, "Error", "MQTT", undefined, `Could not extract DUID from topic: ${topic}`, "warn"); return; } let finalDuid = duid; // Verify identity against known devices const knownDevices = this.adapter.http_api.getDevices(); if (knownDevices.some(d => d.duid === duid)) { // Direct match } else { // Last segment not a known DUID: treat as endpoint-only response topic. // Search backwards through path for any known DUID. const safeParts = [...parts]; for (let i = safeParts.length - 2; i >= 0; i--) { const seg = safeParts[i]; if (knownDevices.some(d => d.duid === seg)) { finalDuid = seg; break; } } } const allMessages = this.adapter.requestsHandler.messageParser.decodeMsg(message, duid); for (const data of allMessages) { await this.handleDecodedMessage(finalDuid, data); } } catch (error: unknown) { this.adapter.rLog("MQTT", null, "Error", "MQTT", undefined, `Error processing MQTT message on ${topic}: ${this.adapter.errorStack(error)}`, "error"); } }); this.adapter.rLog("MQTT", null, "Info", undefined, undefined, `MQTT message listener initialized.`, "info"); } private decryptB01Payload(payload: Buffer, duid: string): Buffer { const devices = this.adapter.http_api.getDevices(); const device = devices.find((d: any) => d.duid === duid); const serial = device?.sn || ""; const model = this.adapter.http_api.getRobotModel(duid) || "roborock.vacuum.a27"; const localKey = this.adapter.http_api.getMatchedLocalKeys().get(duid); const result = B01MapDecryptor.decrypt(payload, serial, model, duid, this.adapter, localKey); return result || payload; } /** * Helper to process the inner JSON of a B01 response. */ private async handleB01Response(response: any, duid: string): Promise<void> { const reqId = Number(response.msgId || response.id); if (isNaN(reqId) || reqId <= 0) { this.adapter.rLog("MQTT", duid, "<-", "B01", undefined, "Message has no valid ID", "warn"); return; } const pending = this.adapter.pendingRequests.get(reqId); if (!pending) { this.handleUnknownB01Response(duid, reqId); return; } // Handle inner payload (Map/Photo) if (response.payload && await this.processB01InnerPayload(duid, reqId, response.payload)) { return; } const result: unknown = response.data ?? response.result ?? response.error; // Special handling for Map/Photo ACKs in B01 const isMapOrPhoto = ["get_map_v1", "get_clean_record_map", "get_photo"].includes(pending.method); const isSuccessOk = result === "ok" || (Array.isArray(result) && result[0] === "ok"); if (isMapOrPhoto && isSuccessOk) { // ACK received, waiting for binary data } else { this.adapter.requestsHandler.resolvePendingRequest(reqId, result, `MQTT-B01`, duid, "MQTT"); } } /** * Extracts and processes the inner 'payload' field from a B01 response. */ private async processB01InnerPayload(duid: string, reqId: number, payloadHex: string): Promise<boolean> { try { const payloadBuf = Buffer.from(payloadHex, "hex"); const finalPayload = this.decryptB01Payload(payloadBuf, duid); if (finalPayload && finalPayload.length > 0) { this.adapter.requestsHandler.resolvePendingRequest(reqId, finalPayload, "B01", duid); return true; } } catch (e) { this.adapter.rLog("MQTT", duid, "Error", "B01", reqId, `Failed to process inner payload: ${e}`, "error"); } return false; } /** * Handles a B01 response that doesn't match any pending request. */ private handleUnknownB01Response(_duid: string, reqId: number): void { if (this.adapter.requestsHandler.isRequestRecentlyFinished(reqId)) { return; } } /** * Processes a single decoded Roborock message frame. */ async handleDecodedMessage(duid: string, data: any): Promise<void> { // 1. Generic A01 / B01 JSON payloads. Specialized protocols (e.g. 102/500) are handled below exactly once. const isGenericJsonProtocol = (data.version === "A01" || data.version === "B01") && ![102, 300, 301, 500].includes(data.protocol); if (isGenericJsonProtocol) { await this.handleProtocolA01B01(duid, data); return; } // 2. Protocol 102 (General Command Response) if (data.protocol === 102) { await this.handleProtocol102(duid, data); return; } // 3. Protocol 300 & 301 & 302 (Binary Data: Photos, Maps, B01 Maps) if (data.protocol === 300 || data.protocol === 301 || data.protocol === 302) { await this.handlePhotoOrMapData(duid, data); return; } // 3b. Protocol 101 (V1 get_photo: binary photo or JSON ACK/error) if (data.protocol === 101 && data.version === "1.0") { if (data.payload.length >= 8 && data.payload.subarray(0, 8).toString("ascii") === "ROBOROCK") { const pending = this.adapter.requestsHandler.getPendingBinaryRequest(data.payload, duid); const msgId = pending && "binaryType" in pending && pending.binaryType === "photo" ? (pending as { messageID?: number }).messageID : undefined; await this.photoManager.handlePhotoProtocol301(duid, data.payload, msgId); return; } if (data.payload.length > 0 && data.payload[0] === 0x7B) { await this.handleProtocol101Response(duid, data); return; } } // 4. Protocol 500 (Device Status / OTA) if (data.protocol === 500) { await this.handleProtocol500(duid, data); return; } // 5. Unknown Protocol const version = await this.adapter.getDeviceProtocolVersion(duid).catch(() => "1.0"); this.adapter.rLog("MQTT", duid, "<-", version, String(data.protocol), "Unknown Protocol", "warn"); } /** * Handles Protocol A01/B01 messages (Tuya-like JSON wrapper). */ private async handleProtocolA01B01(duid: string, data: any): Promise<void> { try { const payloadStr = Buffer.isBuffer(data.payload) ? data.payload.toString("utf8") : String(data.payload ?? ""); const parsedPayload = JSON.parse(payloadStr); if (data.version === "B01") { await this.dispatchB01Message(duid, parsedPayload); } else { await this.adapter.processA01(duid, parsedPayload); } } catch (e) { this.adapter.rLog("MQTT", duid, "Error", data.version, undefined, `Failed to parse payload: ${e}`, "error"); } } /** * Dispatches a B01 JSON message to the appropriate handler. */ private async dispatchB01Message(duid: string, parsedPayload: any): Promise<void> { // Standard B01 Wrapper: { dps: { "10001": { ... } } } if (parsedPayload.dps?.["10001"]) { let inner = parsedPayload.dps["10001"]; if (typeof inner === "string") { try { inner = JSON.parse(inner); } catch (e) { this.adapter.rLog("MQTT", duid, "Error", "B01", undefined, `Failed to parse B01 nested string payload: ${e}`, "warn"); return; } } await this.handleB01Response(inner, duid); } } /** * Handles Protocol 102 (General Command Response). */ private async handleProtocol102(duid: string, data: any): Promise<void> { try { const payloadStr = Buffer.isBuffer(data.payload) ? data.payload.toString("utf8") : String(data.payload ?? ""); const parsed = JSON.parse(payloadStr); let dps102 = parsed.dps?.["102"] || (parsed.dps ? parsed.dps : null); const b01Variant = await this.adapter.getB01Variant(duid); const handler = this.adapter.deviceFeatureHandlers.get(duid); if (typeof dps102 === "string") { dps102 = JSON.parse(dps102); } if (!dps102 || typeof dps102 !== "object") return; const pendingRequest = this.adapter.pendingRequests.get(dps102.id); if (pendingRequest) { await this.resolveProtocol102Request(duid, data.protocol, dps102, pendingRequest); } let handledByQ10 = false; if (b01Variant === "Q10" && handler) { handledByQ10 = await this.q10DpDispatcher.dispatchProtocol102(duid, parsed, dps102); } if (!pendingRequest && !this.adapter.requestsHandler.isRequestRecentlyFinished(dps102.id) && !handledByQ10) { const version = data.version || await this.adapter.getDeviceProtocolVersion(duid).catch(() => "1.0"); this.adapter.rLog("MQTT", duid, "<-", version, String(data.protocol), payloadStr, "debug"); } } catch (e) { this.adapter.rLog("MQTT", duid, "Error", "102", undefined, `Failed to parse Protocol 102: ${e}`, "error"); } } private async resolveProtocol102Request(duid: string, protocol: number, dps102: any, pending: any): Promise<void> { const isBinaryDataMethod = ["get_map_v1", "get_clean_record_map", "get_photo"].includes(pending.method); if (isBinaryDataMethod) { const isSuccessOk = dps102.result === "ok" || (Array.isArray(dps102.result) && dps102.result[0] === "ok"); if (isSuccessOk) { } else { this.adapter.requestsHandler.resolvePendingRequest(dps102.id, dps102.result, protocol, duid, "MQTT"); } } else { this.adapter.requestsHandler.resolvePendingRequest(dps102.id, dps102.result, protocol, duid, "MQTT"); } } /** * Handles Protocol 101 JSON response (V1 get_photo ACK or error). Same semantics as 102: do not resolve on "ok" for get_photo (wait for binary). */ private async handleProtocol101Response(duid: string, data: any): Promise<void> { try { const payloadStr = Buffer.isBuffer(data.payload) ? data.payload.toString("utf8") : String(data.payload ?? ""); const parsed = JSON.parse(payloadStr); const dps101 = parsed.dps?.["101"] ?? (typeof parsed.id !== "undefined" ? parsed : null); const inner = typeof dps101 === "string" ? (() => { try { return JSON.parse(dps101); } catch { return null; } })() : dps101; if (!inner || typeof inner !== "object") return; const pendingRequest = this.adapter.pendingRequests.get(inner.id); if (pendingRequest) { await this.resolveProtocol102Request(duid, 101, inner, pendingRequest); } } catch (e) { this.adapter.rLog("MQTT", duid, "Error", "101", undefined, `Failed to parse Protocol 101 response: ${e}`, "error"); } } /** * Handles Protocol 500 (Device Status / OTA). */ private async handleProtocol500(duid: string, data: any): Promise<void> { try { const payloadStr = Buffer.isBuffer(data.payload) ? data.payload.toString("utf8") : String(data.payload ?? ""); const parsedData = JSON.parse(payloadStr); const version = await this.adapter.getDeviceProtocolVersion(duid).catch(() => "1.0"); if (parsedData.mqttOtaData) { const status = parsedData.mqttOtaData.mqttOtaStatus?.status; const progress = parsedData.mqttOtaData.mqttOtaProgress?.progress; if (status) this.adapter.rLog("MQTT", duid, "Info", version, "500", `Firmware Update Status: ${status}`, "info"); if (progress !== undefined) this.adapter.rLog("MQTT", duid, "Info", version, "500", `Firmware Update Progress: ${progress}%`, "info"); } else if (parsedData.online === false) { this.adapter.rLog("MQTT", duid, "Info", version, "500", `Device OFFLINE.`, "info"); } } catch (error: unknown) { const version = await this.adapter.getDeviceProtocolVersion(duid).catch(() => "1.0"); this.adapter.rLog("MQTT", duid, "Error", version, "500", `Parse Error | ${this.adapter.errorMessage(error)}`, "warn"); } } /** * Handles binary data messages (Protocol 300/301) for Photos or Maps. * B01ChunkAssembler distinguishes Photo (ROBOROCK) vs Map and assembles chunked maps. */ async handlePhotoOrMapData(duid: string, data: any): Promise<void> { const payloadBuf = data.payload as Buffer; const pending: any = this.adapter.requestsHandler.getPendingBinaryRequest(payloadBuf, duid); if (pending) { const binaryType = pending.binaryType; const msgId = (pending as any).messageID; const isMap = binaryType === "map" || (pending as any).method === "get_map_v1" || (pending as any).method === "get_clean_record_map"; if (binaryType === "photo") { if (data.protocol === 300) { await this.photoManager.handlePhotoProtocol300(duid, payloadBuf); } else { await this.photoManager.handlePhotoProtocol301(duid, payloadBuf, msgId); } return; } if (isMap) { if (data.protocol === 302) { await this.handleB01Map(duid, data, payloadBuf); return; } if (data.protocol === 301 && !(payloadBuf.length >= 3 && payloadBuf.subarray(0, 3).toString("ascii") === "B01")) { if (classifyB01MapPayload(payloadBuf).kind === "live") { if (this.resolveAndSendB01Map(duid, payloadBuf, data.protocol)) { return; } if (await this.tryHandleUnsolicitedQ10LiveMap(duid, payloadBuf)) { return; } } await this.handleV1MapPacket(duid, data, payloadBuf); return; } // 300 or 301 with B01 payload: B01 chunked map, fall through to chunkAssembler } } if (await this.q10DpDispatcher.tryHandleCleanRecordBlob(duid, payloadBuf)) { return; } // Continuation chunks (no ROBOROCK header) may not match getPendingBinaryRequest; try PhotoManager if we have a pending photo. if (!pending && data.protocol === 301 && payloadBuf.length >= 3 && payloadBuf.subarray(0, 3).toString("ascii") !== "B01" && this.adapter.requestsHandler.hasPendingPhotoRequest(duid)) { const handled = await this.photoManager.handlePhotoProtocol301(duid, payloadBuf); if (handled) return; } // Gate: pending request is photo (binaryType === "photo") => only PhotoManager, never feed to Map assembler. // Exception: payload starting with "B01" is a map frame, still send to chunkAssembler. if ((data.protocol === 300 || data.protocol === 301) && this.adapter.requestsHandler.hasPendingPhotoRequest(duid) && !(payloadBuf.length >= 3 && payloadBuf.subarray(0, 3).toString("ascii") === "B01")) { const handled = data.protocol === 300 ? await this.photoManager.handlePhotoProtocol300(duid, payloadBuf) : await this.photoManager.handlePhotoProtocol301(duid, payloadBuf); if (handled) return; // Not handled (e.g. out-of-order): skip chunkAssembler so we don't buffer as map and trigger timeout. return; } if (data.protocol === 300 || data.protocol === 301) { const result = await this.chunkAssembler.process(duid, { protocol: data.protocol, seq: data.seq, payload: payloadBuf, payloadLen: data.payloadLen, }); if (result.type === "photo") { if (data.protocol === 300) { await this.photoManager.handlePhotoProtocol300(duid, result.payload); } else { await this.photoManager.handlePhotoProtocol301(duid, result.payload, pending?.messageID); } return; } if (result.type === "map") { if (await this.q10DpDispatcher.tryHandleCleanRecordBlob(duid, result.payload)) { return; } if (this.resolveAndSendB01Map(duid, result.payload, data.protocol)) { return; } if (await this.tryHandleUnsolicitedQ10LiveMap(duid, result.payload)) { return; } return; } if (result.type === "map_chunk_buffered") return; } if (data.protocol === 300) { await this.photoManager.handlePhotoProtocol300(duid, payloadBuf); } else if (data.protocol === 301) { await this.tryHandleB01Map301Single(duid, data); } else if (data.protocol === 302) { await this.handleB01Map(duid, data, payloadBuf); } } /** Resolves decrypted B01 map to pending request (301 logic: taskBeginDate/classify) and sends payload; no-op if no match. */ private resolveAndSendB01Map(duid: string, mapBuf: Buffer, protocol: number): boolean { const foundId = this.resolveB01Map301ToPendingId(duid, mapBuf); if (foundId === -1) return false; this.resolvePendingMapIfFound(foundId, mapBuf, protocol, duid); return true; } /** Sends map payload to pending request when foundId is valid; no-op otherwise. */ private resolvePendingMapIfFound(foundId: number, mapBuf: Buffer, protocol: number, duid: string): void { if (foundId !== -1) { this.adapter.requestsHandler.resolvePendingRequest(foundId, mapBuf, protocol, duid, "MQTT", "B01"); } } private async tryHandleUnsolicitedQ10LiveMap(duid: string, payloadBuf: Buffer): Promise<boolean> { const payloadClassification = classifyB01MapPayload(payloadBuf); if (payloadClassification.variant !== "q10" || payloadClassification.kind !== "live") { return false; } const b01Variant = await this.adapter.getB01Variant(duid).catch(() => null); if (b01Variant !== "Q10") return false; const handler = this.adapter.deviceFeatureHandlers.get(duid) as { applyQ10LiveMapPayload?: (payload: Buffer) => Promise<void>; } | undefined; if (typeof handler?.applyQ10LiveMapPayload !== "function") return false; await handler.applyQ10LiveMapPayload(payloadBuf); return true; } /** Single 301 frame (no chunk assembler): envelope, JSON error log, then decrypt and resolve if B01 map. */ private async tryHandleB01Map301Single(duid: string, data: any): Promise<void> { const payloadBuf = data.payload as Buffer; if (this.handleB01Map301Envelope(duid, data)) return; if (payloadBuf[0] === 0x7B) { this.logIf301IsCloudError(duid, payloadBuf); return; } const liveClassification = classifyB01MapPayload(payloadBuf); if (liveClassification.kind === "live") { const foundId = this.resolveB01Map301ToPendingId(duid, payloadBuf); if (foundId !== -1) { this.resolvePendingMapIfFound(foundId, payloadBuf, data.protocol, duid); return; } if (await this.tryHandleUnsolicitedQ10LiveMap(duid, payloadBuf)) { return; } } const hasB01Signature = (payloadBuf.length >= 3 && payloadBuf.toString("ascii", 0, 3) === "B01") || (payloadBuf.length >= 8 && payloadBuf.subarray(0, 8).toString("ascii") === "ROBOROCK"); if (!hasB01Signature) return; const workingBuf = this.getB01MapBuffer(duid, payloadBuf); if (workingBuf?.length > 0) { if (this.resolveAndSendB01Map(duid, workingBuf, data.protocol)) return; await this.tryHandleUnsolicitedQ10LiveMap(duid, workingBuf); } } /** * Recognizes B01 map 301 envelope: {"roborock":{"ver":"B01","proto":301,"map_id":...},...}. * When recognized: if cloud error → resolve pending with null; else return true. */ private handleB01Map301Envelope(duid: string, data: any): boolean { const payloadBuf = data.payload as Buffer; if (payloadBuf.length < 50 || payloadBuf[0] !== 0x7B) return false; let json: { roborock?: { ver?: string; proto?: number; map_id?: number; error?: string }; ver?: string; proto?: number; map_id?: number; error?: string }; try { json = JSON.parse(payloadBuf.toString("utf8")); } catch { return false; } const roborock = json?.roborock ?? json; if (roborock?.ver !== "B01" || roborock?.proto !== 301 || !("map_id" in roborock)) return false; const pendingId = this.findPendingB01MapRequestByMethod(duid, "get_map_v1"); const id = pendingId !== -1 ? pendingId : this.findPendingB01MapRequestByMethod(duid, "get_clean_record_map"); if (id === -1) return true; if (roborock.error) { this.adapter.rLog("MQTT", duid, "Warn", "B01", undefined, `301 map envelope: cloud error (${roborock.error})`, "warn"); } this.adapter.requestsHandler.resolvePendingRequest(id, null, data.protocol, duid, "MQTT", "B01"); return true; } private async handleV1MapPacket(duid: string, data: any, payloadBuf: Buffer): Promise<void> { if (payloadBuf.length < 24) return; try { const msgId = payloadBuf.readUInt16LE(16); const pending = this.adapter.pendingRequests.get(msgId); if (!pending || pending.duid !== duid) return; // Verify this is actually a map request to avoid ID collisions if (pending.method !== "get_map_v1" && pending.method !== "get_clean_record_map") { return; } const unzipped = this.decryptAndUnzipV1MapCore(payloadBuf.subarray(24)); this.adapter.requestsHandler.resolvePendingRequest(msgId, unzipped, data.protocol, duid, "MQTT", "1.0"); } catch (e: unknown) { this.adapter.rLog("MQTT", duid, "Error", "1.0", String(data.protocol), `V1 map processing failed: ${this.adapter.errorMessage(e)}`, "error"); } } /** V1 map: outer payload decrypted by messageParser; inner is AES-128-CBC (adapter nonce) then gzip. */ private decryptAndUnzipV1MapCore(decryptedPayload: Buffer): Buffer { const iv = Buffer.alloc(16, 0); const decipher = crypto.createDecipheriv("aes-128-cbc", this.adapter.nonce, iv); let decrypted = decipher.update(decryptedPayload as Uint8Array); decrypted = Buffer.concat([decrypted as Uint8Array, decipher.final()]); return zlib.gunzipSync(decrypted as Uint8Array); } /** Handles B01 map (protocol 302): decrypt then resolve to first pending map request (FIFO). */ private async handleB01Map(duid: string, data: any, payloadBuf: Buffer): Promise<void> { try { const workingBuf = this.getB01MapBuffer(duid, payloadBuf); this.resolvePendingMapIfFound(this.findPendingB01MapRequest(duid), workingBuf, data.protocol, duid); } catch (e: unknown) { this.adapter.rLog("MQTT", duid, "Error", "B01", undefined, `B01 Map processing failed: ${this.adapter.errorMessage(e)}`, "error"); } } /** Decrypts B01 map payload. Callers (301 after envelope/0x7B skip, 302 chunk result) pass raw Layer1 payload only. */ private getB01MapBuffer(duid: string, payloadBuf: Buffer): Buffer { const processed = this.decryptB01Payload(payloadBuf, duid); return (processed && processed.length > 0) ? processed : payloadBuf; } /** * Classifies decrypted B01 map payload as live (upload_by_maptype) or history (upload_record_by_url) by protobuf prefix. * Observed: live 301 first bytes 08 00 12 3c...; history 08 15 12 40... (field 1 value 0 vs 21). */ private classifyB01Map301Payload(decryptedBuf: Buffer): "get_map_v1" | "get_clean_record_map" | null { if (decryptedBuf.length < 3) return null; if (decryptedBuf[0] !== 0x08 || decryptedBuf[2] !== 0x12) return null; if (decryptedBuf[1] === 0x00) return "get_map_v1"; // live if (decryptedBuf[1] === 0x15) return "get_clean_record_map"; // history return null; } /** Removes the first queue entry that matches the given method; returns true if removed. */ private shiftFirstMatchingB01MapMethod(duid: string, method: "get_map_v1" | "get_clean_record_map"): boolean { const queue = this.adapter.b01MapResponseQueue?.get(duid); if (!queue || queue.length === 0) return false; const idx = queue.indexOf(method); if (idx === -1) return false; queue.splice(idx, 1); if (queue.length === 0) this.adapter.b01MapResponseQueue!.delete(duid); else this.adapter.b01MapResponseQueue!.set(duid, queue); return true; } /** * Resolves the 301 payload to the correct pending map request ID. * History: taskBeginDate (from protobuf) only. * Live: classify by payload prefix (08 00 12) + queue, then match by method. */ private resolveB01Map301ToPendingId(duid: string, payloadBuf: Buffer): number { const isHistoryProtobuf = payloadBuf.length >= 3 && payloadBuf[0] === 0x08 && payloadBuf[1] === 0x15 && payloadBuf[2] === 0x12; if (isHistoryProtobuf) { const taskBeginDate = this.extractTaskBeginDate(payloadBuf); if (taskBeginDate != null) { for (const [id, req] of this.adapter.pendingRequests) { if ((req as any).duid === duid && (req as any).method === "get_clean_record_map" && (req as any).startTime === taskBeginDate) { return typeof id === "number" ? id : -1; } } } return -1; } const classifiedMethod = this.classifyB01Map301Payload(payloadBuf); if (classifiedMethod) { this.shiftFirstMatchingB01MapMethod(duid, classifiedMethod); return this.findPendingB01MapRequestByMethod(duid, classifiedMethod); } const payloadClassification = classifyB01MapPayload(payloadBuf); if ( payloadClassification.variant === "q10" && payloadClassification.kind === "live" && payloadClassification.q10?.mapData ) { const livePendingId = this.findPendingB01MapRequestByMethod(duid, "get_map_v1"); if (livePendingId !== -1) { this.shiftFirstMatchingB01MapMethod(duid, "get_map_v1"); return livePendingId; } } return -1; } /** Returns the first pending map request for this duid (used for protocol 302 chunked map). */ private findPendingB01MapRequest(duid: string): number { for (const [id, req] of this.adapter.pendingRequests) { if ((req.method === "get_map_v1" || req.method === "get_clean_record_map") && req.duid === duid) { return id; } } return -1; } private extractTaskBeginDate(buffer: Buffer): number | null { if (!buffer || buffer.length < 3) return null; try { if (!cachedB01RobotMapType) { const root = protobuf.parse(ROBOROCK_PROTO_STR).root; cachedB01RobotMapType = root.lookupType("SCMap.RobotMap"); } const decoded = cachedB01RobotMapType.decode(buffer) as { mapExtInfo?: { taskBeginDate?: number } }; const taskBeginDate = decoded?.mapExtInfo?.taskBeginDate; return taskBeginDate != null && typeof taskBeginDate === "number" ? (taskBeginDate >>> 0) : null; } catch { return null; } } /** Returns the id of a pending request for this duid with the given method (get_map_v1 = live, get_clean_record_map = history). */ private findPendingB01MapRequestByMethod(duid: string, method: "get_map_v1" | "get_clean_record_map"): number { for (const [id, req] of this.adapter.pendingRequests) { if ((req as any).method === method && (req as any).duid === duid) return id; } return -1; } /** Called when 301 payload is JSON (starts with 0x7B). Logs cloud error message if present. */ private logIf301IsCloudError(duid: string, payloadBuf: Buffer): void { if (payloadBuf.length < 20 || payloadBuf[0] !== 0x7B) return; try { const json = JSON.parse(payloadBuf.toString("utf8")); const err = json?.roborock?.error ?? json?.error ?? json?.message; if (err && typeof err === "string") { this.adapter.rLog("MQTT", duid, "Warn", "B01", undefined, `301 cloud error: ${err}`, "warn"); } } catch { // not JSON } } /** * Ensures that a valid endpoint string exists for this adapter instance. */ async ensureEndpoint(): Promise<string> { const endpointState = await this.adapter.getStateAsync("endpoint"); if (!endpointState || !endpointState.val) { const rriot = this.adapter.http_api.get_rriot(); const randomEndpoint = this.md5bin(rriot.k).subarray(8, 14).toString("base64"); await this.adapter.setState("endpoint", { val: randomEndpoint, ack: true }); return randomEndpoint; } return endpointState.val as string; } /** * Publishes a message to the MQTT broker. */ async sendMessage(duid: string, roborockMessage: Buffer): Promise<void> { if (this.client && this.connected) { const rriot = this.adapter.http_api.get_rriot(); const topic = `rr/m/i/${rriot.u}/${this.mqttUser}/${duid}`; this.client.publish(topic, roborockMessage, { qos: 1 }); } } isConnected(): boolean { return this.connected; } async disconnectClient(): Promise<void> { if (this.client) { try { this.client.end(); this.connected = false; } catch (error) { this.adapter.rLog("MQTT", null, "Error", "MQTT", undefined, `Failed to disconnect: ${error}`, "error"); } } } md5hex(str: string): string { return crypto.createHash("md5").update(str).digest("hex"); } md5bin(str: string): Buffer { return crypto.createHash("md5").update(str).digest(); } public clearIntervals(): void { if (this.reconnectTimer) { this.adapter.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.photoManager) { this.photoManager.clearIntervals(); } this.client?.end(); this.client = undefined; } cleanup(): void { if (this.client) { this.client.removeAllListeners(); this.client.end(); this.client = null; } this.connected = false; this.clearIntervals(); } }