iobroker.roborock
Version:
949 lines (831 loc) • 35.1 kB
text/typescript
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();
}
}