iobroker.roborock
Version:
760 lines (651 loc) • 30.2 kB
text/typescript
import PQueue from "p-queue";
import type { Roborock } from "../main";
import { Q10CommandHandler } from "./b01/q10/Q10CommandHandler";
import { isConnectivityLikeError } from "./errorUtils";
import type { BaseDeviceFeatures } from "./features/baseDeviceFeatures";
import { messageParser } from "./messageParser";
const REQUEST_TIMEOUT = 10000;
/** Max retries on failure (timeout/network); total attempts = 1 + this value. */
const MAX_REQUEST_RETRIES = 2;
function isRetryableError(error: unknown): boolean {
return isConnectivityLikeError(error);
}
export enum RequestPriority {
LOW = -10,
NORMAL = 0,
HIGH = 10
}
// ============================================================
// RequestManager Class
// ============================================================
class RequestManager {
queue: PQueue;
timeoutMs: number;
tasks: Map<string, AbortController>;
constructor(concurrency = 20, timeoutMs = 30000) {
this.queue = new PQueue({ concurrency });
this.timeoutMs = timeoutMs;
this.tasks = new Map();
}
async add<T>(id: string, taskFunction: (signal: AbortSignal) => Promise<T>, priority = RequestPriority.NORMAL): Promise<T> {
const manualController = new AbortController();
this.tasks.set(id, manualController);
try {
return await this.queue.add(async () => {
try {
if (manualController.signal.aborted) {
throw new Error("ADAPTER_STOPPED");
}
// The 30s timeout is now handled INSIDE RoborockRequest.send()
// This signal is only for manual cancellation (adapter stop)
return await taskFunction(manualController.signal);
} catch (error: unknown) {
if (manualController.signal.aborted || (error instanceof Error && error.message === "CANCELLED_BY_USER")) {
throw new Error(`Task ${id} was cancelled manually.`);
} else {
throw error;
}
}
}, { priority });
} finally {
this.tasks.delete(id);
}
}
cancel(id: string) {
const controller = this.tasks.get(id);
if (controller) {
controller.abort();
return true;
}
return false;
}
async onIdle() {
await this.queue.onIdle();
}
clear() {
this.tasks.forEach((c) => c.abort(new Error("ADAPTER_STOPPED")));
this.tasks.clear();
this.queue.clear();
}
}
/** B01 map-style pending entry (method, duid, resolve, reject; optional startTime/recordIndex for history map matching). */
export type PendingMapEntry = {
method: string;
duid: string;
resolve: (data: any) => void;
reject: (err?: any) => void;
startTime?: number;
recordIndex?: number | null;
recordMapUrl?: string;
};
export class RoborockRequest {
adapter: Roborock;
handler: requestsHandler;
duid: string;
method: string;
params: unknown;
messageID: number;
resolvePromise!: (value: unknown) => void;
rejectPromise!: (reason?: unknown) => void;
promise: Promise<unknown>;
version: string; // Store protocol version for logging
public creationTime: number;
public startTime: number = 0;
public binaryType: "photo" | "map" | undefined;
public sentConnectionType: "MQTT" | "TCP" | undefined;
timeoutTimer: ioBroker.Timeout | undefined;
timeout: number;
manager: RequestManager;
queueName: string;
constructor(handler: requestsHandler, duid: string, method: string, params: unknown, manager: RequestManager, queueName: string, version = "1.0", timeout = REQUEST_TIMEOUT) {
this.handler = handler;
this.adapter = handler.adapter;
this.duid = duid;
this.method = method;
this.params = params;
this.messageID = 0;
this.version = version;
this.creationTime = Date.now();
this.startTime = Date.now();
this.timeout = timeout;
this.manager = manager;
this.queueName = queueName;
// Set strict binary type based on method
if (this.method === "get_photo") {
this.binaryType = "photo";
} else if (this.method === "get_map_v1" || this.method === "get_clean_record_map") {
this.binaryType = "map";
}
this.promise = new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
// Prevent unhandled rejections if we reject before anyone awaits it
this.promise.catch(() => { });
}
async send(signal?: AbortSignal) {
const queueDuration = Date.now() - this.creationTime;
if (signal?.aborted) throw new Error("Aborted");
// Assign fresh Message ID right before sending
if (this.method === "get_photo") {
this.handler.photoIdCounter = this.handler.photoIdCounter >= 255 ? 1 : this.handler.photoIdCounter + 1;
this.messageID = this.handler.photoIdCounter;
} else {
if (this.version === "B01") {
this.messageID = Date.now();
if (this.messageID <= this.handler.lastB01Id) {
this.messageID = this.handler.lastB01Id + 1;
}
this.handler.lastB01Id = this.messageID;
} else {
this.messageID = this.handler.nextMessageId();
}
}
// Register in pendingRequests immediately to ensure we can cleanup if anything fails
this.adapter.pendingRequests.set(this.messageID, this);
// B01: FIFO queue so 301 responses can be matched by order (live vs history from classify + taskBeginDate)
if (this.version === "B01" && (this.method === "service.upload_by_maptype" || this.method === "service.upload_record_by_url")) {
const queue = this.adapter.b01MapResponseQueue.get(this.duid) || [];
queue.push(this.method === "service.upload_by_maptype" ? "get_map_v1" : "get_clean_record_map");
this.adapter.b01MapResponseQueue.set(this.duid, queue);
}
// Handle Manual Cancellation
if (signal) {
signal.addEventListener("abort", () => {
this.reject(new Error(`Task ${this.messageID} was cancelled manually.`));
}, { once: true });
}
let protocol = 101;
let version = await this.adapter.getDeviceProtocolVersion(this.duid);
const timestamp = Math.floor(Date.now() / 1000);
// FORCE Protocol 1.0 for get_photo as it uses a specific RSA handshake
// that is known to work with 1.0 but fails/times out with L01.
if (this.method === "get_photo") {
version = "1.0";
}
if (this.adapter.local_api.isConnected(this.duid) && version != "B01" && !["service.upload_by_maptype", "service.upload_record_by_url", "get_photo"].includes(this.method)) {
protocol = 4;
}
const payload = await this.handler.messageParser.buildPayload(protocol, this.messageID, this.method, this.params, version);
const mqttConnectionState = this.adapter.mqtt_api.isConnected();
// Connection inference
const connectionType = (protocol == 101) ? "MQTT" : "TCP";
this.sentConnectionType = connectionType;
const reduceLog = this.method === "get_clean_summary" || this.method === "service.get_record_list" || this.method === "get_photo";
const paramsStr = this.params != null ? JSON.stringify(this.params) : "";
const logParams = reduceLog
? (paramsStr ? ` | Params (${paramsStr.length < 1024 ? `${paramsStr.length}b` : `${(paramsStr.length / 1024).toFixed(1)} KB`})` : "")
: (paramsStr ? ` | Params: ${paramsStr}` : "");
const logLevel = requestsHandler.getLogLevelForMethod(this.method);
const qSize = this.manager.queue.size;
// Do not show internal request ID for B01 301 map methods.
const logMsgId = (version === "B01" && (this.method === "get_clean_record_map" || this.method === "get_map_v1")) ? undefined : this.messageID;
// Local TCP uses a socket msgId separate from the JSON request id; L01 also
// feeds that frame id into AES-GCM/replay state.
const transportMessageId = protocol === 4 ? this.handler.messageParser.nextTransportSequenceId(this.duid) : this.messageID;
const tcpFrameLog = protocol === 4 ? ` | tcpMsgId: ${transportMessageId}` : "";
if (connectionType === "MQTT") {
this.adapter.rLog("MQTT", this.duid, "->", `${version}`, protocol, `${this.method}${logParams} | qSize: ${qSize} | waited: ${queueDuration}ms`, logLevel, logMsgId);
} else {
this.adapter.rLog("TCP", this.duid, "->", `${version}`, protocol, `${this.method}${logParams}${tcpFrameLog} | qSize: ${qSize} | waited: ${queueDuration}ms`, "debug", logMsgId);
}
const roborockMessage = await this.handler.messageParser.buildRoborockMessage(this.duid, protocol, timestamp, payload, version, transportMessageId);
const localConnectionState = this.adapter.local_api.isConnected(this.duid);
if (!roborockMessage) {
const errorMsg = "Failed to build buildRoborockMessage!";
this.adapter.catchError(errorMsg, "function sendRequest", this.duid);
this.reject(new Error(errorMsg));
return this.promise;
}
if (version == "A01") {
this.adapter.mqtt_api.sendMessage(this.duid, roborockMessage);
this.resolve(null);
return this.promise;
}
if (protocol == 101 && !mqttConnectionState) {
const errorMsg = `Cloud connection not available. Not sending for method ${this.method} request!`;
this.adapter.rLog("System", this.duid, "Debug", "N/A", undefined, errorMsg, "debug");
this.reject(new Error(errorMsg));
return this.promise;
} else if (!localConnectionState && !mqttConnectionState && this.method != "get_network_info") {
const errorMsg = `Adapter locally or remotely not connected to robot ${this.duid}. Sending request for ${this.method} not possible!`;
this.adapter.rLog("System", this.duid, "Debug", "N/A", undefined, errorMsg, "debug");
this.reject(new Error(errorMsg));
return this.promise;
}
if (protocol !== 101 && !localConnectionState) {
const errorMsg = `TCP network connection unavailable before sending ${this.method}.`;
this.adapter.rLog("TCP", this.duid, "Debug", `${version}`, protocol, errorMsg, "debug", logMsgId);
this.reject(new Error(errorMsg));
return this.promise;
}
// --- Start Precision Timer NOW ---
// We start the timer only when the message is about to touch the wire.
this.startTime = Date.now();
this.timeoutTimer = this.adapter.setTimeout(() => {
const runDuration = Date.now() - this.startTime;
this.adapter.rLog("System", this.duid, "Debug", "Lifecycle", undefined, `[Task ${this.messageID}] TIMEOUT (${this.method}) after ${runDuration}ms | limit: ${this.timeout}ms`, "debug");
this.reject(new Error(`Task ${this.messageID} timed out after ${this.timeout}ms.`));
}, this.timeout);
// Use the forced connectionType logic for decision making
if (protocol == 101) {
this.adapter.mqtt_api.sendMessage(this.duid, roborockMessage);
} else {
const lengthBuffer = Buffer.alloc(4);
lengthBuffer.writeUInt32BE(roborockMessage.length, 0);
const fullMessage = Buffer.concat([lengthBuffer, roborockMessage] as Uint8Array[]);
if (!this.adapter.local_api.sendMessage(this.duid, fullMessage)) {
this.reject(new Error(`TCP network connection unavailable while sending ${this.method}.`));
}
}
return this.promise;
}
resolve(result: unknown, version?: string) {
if (this.timeoutTimer) this.adapter.clearTimeout(this.timeoutTimer);
this.adapter.pendingRequests.delete(this.messageID);
// Return an object if version is present, otherwise just the result
if (version) {
this.resolvePromise({ data: result, version: version });
} else {
this.resolvePromise(result);
}
}
reject(reason: unknown) {
if (this.timeoutTimer) this.adapter.clearTimeout(this.timeoutTimer);
// Debug logging to verify deletion
const existed = this.adapter.pendingRequests.delete(this.messageID);
if (!existed) {
// This might happen if someone else deleted it, which shouldn't happen with single-threaded guard
this.adapter.rLog("System", this.duid, "Debug", "Requests", undefined, `Warning: MessageID ${this.messageID} was NOT found in pendingRequests during rejection.`, "debug");
}
this.rejectPromise(reason);
}
}
export class requestsHandler {
adapter: Roborock;
idCounter: number;
photoIdCounter: number;
lastB01Id: number; // For B01 timestamp-based IDs
private globalManager: RequestManager; // For commands (high concurrency)
/** Map-related requests for non-B01 (V1 etc.). Concurrency 2; responses are matched by message ID. */
private mapManager: RequestManager;
/** B01 only: map triggers and logical map requests. Concurrency 1 — B01 301 responses are not matched by ID, so only one in flight avoids wrong assignment (e.g. records.0 getting wrong payload). */
private b01MapManager: RequestManager;
private q10CommandHandler: Q10CommandHandler | undefined;
messageParser: messageParser;
mqttResetInterval: ioBroker.Interval | undefined = undefined;
public startupFinished: boolean = true;
private finishedRequests: Set<number> = new Set();
constructor(adapter: Roborock) {
this.adapter = adapter;
// Offset ID by instance to avoid collisions
this.idCounter = (this.adapter.instance * 20000) + 300;
this.photoIdCounter = 0;
this.lastB01Id = 0;
this.globalManager = new RequestManager(10, REQUEST_TIMEOUT); // Fast commands queue
this.mapManager = new RequestManager(2, REQUEST_TIMEOUT); // Non-B01 maps: match by ID
this.b01MapManager = new RequestManager(1, REQUEST_TIMEOUT); // B01 maps only: concurrency 1
this.messageParser = new messageParser(this.adapter);
this.scheduleMqttReset();
}
private getQ10CommandHandler(): Q10CommandHandler {
if (!this.q10CommandHandler) {
this.q10CommandHandler = new Q10CommandHandler(this);
}
return this.q10CommandHandler;
}
private static readonly POLL_METHODS = [
"get_prop",
"prop.get",
"get_consumable",
"get_timer",
"get_network_info",
"get_room_mapping",
"get_multi_maps_list",
"get_clean_summary",
"get_clean_record",
"get_clean_record_map",
"get_map_v1",
"get_photo",
"service.get_record_list",
"service.upload_record_by_url",
"service.upload_by_maptype"
];
private getMinMessageId(): number {
return (this.adapter.instance * 20000) + 300;
}
private getMaxMessageId(): number {
return (this.adapter.instance * 20000) + 20000;
}
public nextMessageId(): number {
if (this.idCounter >= this.getMaxMessageId()) {
this.resetMessageIdCounter("wrapped");
}
this.idCounter += 1;
return this.idCounter;
}
private resetMessageIdCounter(reason: "scheduled" | "wrapped"): void {
const previousId = this.idCounter;
const resetId = this.getMinMessageId();
const resetReason = reason === "scheduled" ? "scheduled reset" : "counter wrap";
this.adapter.rLog("System", null, "Debug", "N/A", undefined, `Resetting request ID counter (${resetReason}) from ${previousId} to ${resetId}. TCP transport sequence remains independent.`, "debug");
this.idCounter = resetId;
}
public static getLogLevelForMethod(method: string): "info" | "debug" {
const m = method.toLowerCase();
const isPoll = requestsHandler.POLL_METHODS.some(pollMethod => m.includes(pollMethod)) || m.startsWith("service.get");
return isPoll ? "debug" : "info";
}
private scheduleMqttReset() {
if (this.mqttResetInterval) this.adapter.clearInterval(this.mqttResetInterval);
this.mqttResetInterval = this.adapter.setInterval(() => {
this.resetMessageIdCounter("scheduled");
}, 24 * 60 * 60 * 1000); // 24h
}
async waitForStartup() {
this.adapter.rLog("System", null, "Info", "Startup", undefined, `[Startup] Skipping wait for initial requests...`, "info");
this.startupFinished = true;
this.adapter.rLog("System", null, "Info", "Startup", undefined, "[Startup] All initial requests finished. Adapter is ready.", "info");
}
public _processResult<T>(requestPromise: Promise<T>, callback: (result: T) => Promise<void>, identifier: string, duid: string): void {
const executionWrapper = async () => {
try {
const result = await requestPromise;
await callback(result);
} catch (e: unknown) {
const errorMsg = this.adapter.errorMessage(e);
// Handle timeouts/aborts gracefully
if (errorMsg.includes("Timeout") || errorMsg.includes("timed out") || errorMsg.includes("Aborted") || errorMsg.includes("CANCELLED") || errorMsg.includes("ADAPTER_STOPPED")) {
const idMatch = errorMsg.match(/Task (req_\d+_\d+)/);
const reqId = idMatch ? idMatch[1] : "unknown";
if (errorMsg.includes("ADAPTER_STOPPED")) {
this.adapter.rLog("System", duid, "Warn", "N/A", undefined, `[${identifier}] Request cancelled (Adapter stopped). ID: ${reqId}`, "warn");
} else {
// Standardized Timeout Log (using dynamic timeout from error if possible)
const timeoutLimit = errorMsg.match(/limit: (\d+)ms/) || errorMsg.match(/after (\d+)ms/);
const displayLimit = timeoutLimit ? timeoutLimit[1] : REQUEST_TIMEOUT;
this.adapter.rLog("System", duid, "Warn", "Timeout", undefined, `Request ${identifier} timed out after ${displayLimit}ms. ID: ${reqId}`, "warn");
}
} else {
this.adapter.catchError(e, `Processing-${identifier}`, duid);
}
}
};
const promise = executionWrapper();
promise.catch(() => {});
}
async sendRequest(duid: string, method: string, params: unknown, options: { priority?: number; timeout?: number } = {}) {
const version = await this.adapter.getDeviceProtocolVersion(duid);
let manager = this.globalManager;
let queueName = "CommandQueue";
const isB01MapMethod =
method === "get_map_v1" ||
method === "get_clean_record_map" ||
method === "service.upload_by_maptype" ||
method === "service.upload_record_by_url";
if (version === "B01" && isB01MapMethod) {
manager = this.b01MapManager;
queueName = "B01MapQueue";
} else if (method === "get_map_v1" || method === "get_clean_record_map" || method === "get_photo") {
manager = this.mapManager;
queueName = "MapQueue";
}
const priority = options.priority ?? RequestPriority.NORMAL;
let timeout = options.timeout ?? REQUEST_TIMEOUT;
// Map and room-related requests need more time (especially on slow connections or when robot is busy)
if (method.includes("map") || method.includes("room") || method === "get_clean_record_map" || method === "get_photo") {
timeout = 20000;
}
const attempt = async (retryCount: number): Promise<unknown> => {
const req = new RoborockRequest(this, duid, method, params, manager, queueName, version, timeout);
if (method === "get_photo") {
req.binaryType = "photo";
} else if (method === "get_map_v1" || method === "get_clean_record_map") {
req.binaryType = "map";
}
const taskId = `${method}_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
try {
const result = await manager.add(taskId, (signal) => req.send(signal), priority);
if (Array.isArray(result) && result[0] === "retry" && retryCount < MAX_REQUEST_RETRIES) {
this.adapter.rLog("System", duid, "Debug", "Retry", undefined, `[sendRequest] Received 'retry' for ${method} on ${duid}. Retrying (${retryCount + 1}/${MAX_REQUEST_RETRIES + 1})...`, "debug");
await new Promise((resolve) => setTimeout(resolve, 1000));
return attempt(retryCount + 1);
}
return result;
} catch (error) {
if (retryCount < MAX_REQUEST_RETRIES && isRetryableError(error)) {
this.adapter.rLog("System", duid, "Warn", "Retry", undefined, `[sendRequest] ${method} failed (${(error as Error).message}). Retrying (${retryCount + 1}/${MAX_REQUEST_RETRIES + 1})...`, "warn");
await new Promise((resolve) => setTimeout(resolve, 1000));
return attempt(retryCount + 1);
}
throw error;
}
};
return attempt(0);
}
/**
* Q10/B01: publishes a raw DP payload via protocol 101 without waiting for a
* correlated response. The device answers via async DP shadow / protocol 102 / 301.
*/
public async publishB01Dp(duid: string, dps: Record<string, unknown>): Promise<void> {
const timestamp = Math.floor(Date.now() / 1000);
const payload = JSON.stringify({ dps, t: timestamp });
const roborockMessage = await this.messageParser.buildRoborockMessage(duid, 101, timestamp, payload, "B01");
if (!roborockMessage) {
throw new Error("Failed to build B01 DP message");
}
await this.adapter.mqtt_api.sendMessage(duid, roborockMessage);
this.adapter.rLog("MQTT", duid, "->", "B01", 101, `Q10 DP publish: ${JSON.stringify(dps)}`, "debug");
}
async command(_handler: BaseDeviceFeatures, duid: string, method: string, params?: unknown, id?: string) {
const b01Variant = await this.adapter.getB01Variant?.(duid);
if (b01Variant === "Q10") {
await this.getQ10CommandHandler().handleCommand(_handler, duid, method, params);
return;
}
let finalParams = params;
let finalMethod = method;
if (_handler) {
const intercepted = await _handler.getCommandParams(method, params, id);
if (typeof intercepted === "object" && intercepted !== null && "method" in intercepted && "params" in intercepted) {
finalMethod = (intercepted as any).method;
finalParams = (intercepted as any).params;
} else {
finalParams = intercepted;
}
}
const requestPromise = this.sendRequest(duid, finalMethod, finalParams, {
priority: 1,
timeout: method === "load_multi_map" ? 20000 : undefined
});
this._processResult(
requestPromise,
async (res: any) => {
// Command success validation for set_ commands
if (method.startsWith("set_")) {
const data = (res && typeof res === "object" && "data" in res) ? res.data : res;
const isOk = Array.isArray(data) && data.length === 1 && data[0] === "ok";
if (!isOk) {
this.adapter.rLog("System", duid, "Error", "Command", undefined, `Command ${method} returned unexpected result: ${JSON.stringify(data)} (Expected: ["ok"])`, "error");
}
}
await _handler?.onCommandResult?.(method, finalMethod, res, finalParams);
// Status refresh after command is done in resolvePendingRequest.
},
`command-${method}-${duid}`,
duid
);
}
public resolvePendingRequest(messageID: number, result: unknown, protocol?: unknown, duid?: string, connectionType: string = "Unknown", version?: string): void {
const req = this.adapter.pendingRequests.get(messageID);
if (req) {
const reqDuid = (req as any).duid || (duid || "unknown");
const method = (req as any).method || "";
// Log response details; for get_clean_summary / service.get_record_list / get_photo show payload size only
const reduceLog = method === "get_clean_summary" || method === "service.get_record_list" || method === "get_photo";
let extraInfo = "";
if (Buffer.isBuffer(result)) {
extraInfo = `Buffer (${result.length}b)`;
} else if (typeof result === "object" && result !== null) {
const resAny = result as any;
if (resAny.buffer && Buffer.isBuffer(resAny.buffer)) {
const bboxStr = (method === "get_photo" || reduceLog) ? "" : (resAny.bbox ? ` | BBox: [${resAny.bbox.x},${resAny.bbox.y},${resAny.bbox.w},${resAny.bbox.h}]` : "");
extraInfo = `[Binary] Payload: ${(resAny.buffer.length / 1024).toFixed(1)} KB${bboxStr}`;
} else if (resAny.map && Buffer.isBuffer(resAny.map)) {
extraInfo = `[Binary] Map: ${(resAny.map.length / 1024).toFixed(1)} KB`;
} else {
const json = JSON.stringify(result);
extraInfo = reduceLog
? `Payload (${json.length < 1024 ? `${json.length}b` : `${(json.length / 1024).toFixed(1)} KB`})`
: json;
}
} else {
const str = String(result);
extraInfo = reduceLog ? `Payload (${str.length}b)` : str;
}
if (protocol) {
const now = Date.now();
const totalDuration = req instanceof RoborockRequest ? now - req.creationTime : 0;
const execDuration = req instanceof RoborockRequest ? now - req.startTime : 0;
const queueDuration = req instanceof RoborockRequest ? req.startTime - req.creationTime : 0;
const qSize = req instanceof RoborockRequest ? req.manager.queue.size : 0;
const durationStr = totalDuration > 0 ? `Total: ${totalDuration}ms (Queue: ${queueDuration}ms, Exec: ${execDuration}ms, qSize: ${qSize})` : "";
const reqVersion = (req as any).version || version || "1.0";
const logLevel = requestsHandler.getLogLevelForMethod(method);
// Do not show internal request ID for B01 301 map responses (get_clean_record_map / get_map_v1).
const logMsgId = (reqVersion === "B01" && (method === "get_clean_record_map" || method === "get_map_v1")) ? undefined : messageID;
this.adapter.rLog(connectionType as any, reqDuid, "<-", reqVersion, String(protocol), `${method}: ${extraInfo} | ${durationStr}`, logLevel, logMsgId);
}
// Add to finished set to prevent race conditions
this.finishedRequests.add(messageID);
this.adapter.setTimeout(() => {
this.finishedRequests.delete(messageID);
}, 60000);
if (typeof (req as any).resolve === "function") {
// Special Handling for Map Requests via TCP
// If we get ["ok"] via TCP, it's just an ACK. The real data comes via MQTT.
// We must NOT resolve the promise yet.
if ((method === "get_map_v1" || method === "get_clean_record_map") &&
Array.isArray(result) && result.length === 1 && result[0] === "ok") {
// TCP ACK received. Keeping request pending for MQTT data...
// Important: Do NOT delete from pendingRequests yet!
this.finishedRequests.delete(messageID); // Also remove from finished set so we can process the real response later
return;
}
(req as any).resolve(result, version);
} else {
if (req instanceof RoborockRequest) {
// Same check for RoborockRequest
if ((method === "get_map_v1" || method === "get_clean_record_map") &&
Array.isArray(result) && result.length === 1 && result[0] === "ok") {
this.finishedRequests.delete(messageID);
return;
}
req.resolve(result, version);
}
}
const isPollMethod = requestsHandler.POLL_METHODS.some((m) => method.includes(m)) || method.startsWith("service.get");
if (!isPollMethod) {
const handler = this.adapter.deviceManager?.deviceFeatureHandlers?.get(reqDuid);
if (handler) {
handler.updateStatus().catch((e: unknown) => {
this.adapter.rLog("System", reqDuid, "Error", "Command", undefined, `Status update after ${method}: ${(e as Error).message}`, "error");
});
}
}
this.adapter.pendingRequests.delete(messageID);
}
}
public rejectPendingTcpRequests(duid: string, reason: string): number {
let rejected = 0;
for (const req of Array.from(this.adapter.pendingRequests.values())) {
if (!(req instanceof RoborockRequest)) continue;
if (req.duid !== duid || req.sentConnectionType !== "TCP") continue;
req.reject(new Error(`TCP network session reset for ${duid}: ${reason}`));
rejected += 1;
}
if (rejected > 0) {
this.adapter.rLog("TCP", duid, "Warn", undefined, undefined, `Rejected ${rejected} pending TCP request(s): ${reason}`, "warn");
}
return rejected;
}
isRequestRecentlyFinished(messageID: number): boolean {
return this.finishedRequests.has(messageID);
}
clearQueue() {
this.adapter.local_api.clearLocalDevicedTimeout();
this.adapter.mqtt_api.clearIntervals();
// Clear global queue
this.globalManager.clear();
this.mapManager.clear();
this.b01MapManager.clear();
// Reject pending requests
this.adapter.pendingRequests.forEach((req) => {
req.reject(new Error("Queue cleared (adapter stopped or disconnected)"));
});
this.adapter.pendingRequests.clear();
}
/** Returns true if there is any pending get_photo request for the given duid (used for continuation chunks without ROBOROCK header). */
public hasPendingPhotoRequest(duid: string): boolean {
for (const req of this.adapter.pendingRequests.values()) {
const r = req as RoborockRequest;
if (r?.method === "get_photo" && r.duid === duid) return true;
}
return false;
}
/** Returns true if there is any pending map request (get_map_v1 / get_clean_record_map) for the given duid. */
public hasPendingMapRequest(duid: string): boolean {
for (const req of this.adapter.pendingRequests.values()) {
const r = req as RoborockRequest & PendingMapEntry;
if ((r?.method === "get_map_v1" || r?.method === "get_clean_record_map") && r.duid === duid) return true;
}
return false;
}
/** Returns the pending map request for duid if exactly one exists (for B01 payload without ID in header). */
public getPendingMapRequest(duid: string): RoborockRequest | PendingMapEntry | undefined {
let found: RoborockRequest | PendingMapEntry | undefined;
for (const req of this.adapter.pendingRequests.values()) {
const r = req as RoborockRequest & PendingMapEntry;
if ((r?.method === "get_map_v1" || r?.method === "get_clean_record_map") && r.duid === duid) {
if (found) return undefined; // more than one, don't guess
found = r;
}
}
return found;
}
/**
* Extracts the global Request ID from binary headers and returns the corresponding pending request.
* Checks offset 8 (ROBOROCK header LSB) and offset 16 (Map header/Protocol 30x full ID).
* For headerless Type 0 streams uses DUID context when ID is not in payload.
*/
public getPendingBinaryRequest(payloadBuf: Buffer, duid: string): RoborockRequest | PendingMapEntry | undefined {
if (payloadBuf.length < 4) return undefined;
try {
const id8 = payloadBuf.length >= 12 ? payloadBuf.readUInt32LE(8) : -1;
const id16 = payloadBuf.length >= 18 ? payloadBuf.readUInt16LE(16) : -1;
// 1. Photo first: Explicit ID Check - Offset 8 (ROBOROCK header ID field)
if (payloadBuf.length >= 12 && payloadBuf.subarray(0, 8).toString("ascii").startsWith("ROBOROCK")) {
const match8 = this.adapter.pendingRequests.get(id8);
if (match8 && match8.duid === duid) {
return match8;
}
}
// 2. Map: Explicit ID Check - Offset 16 (Map header / Universal 30x Protocol ID)
if (payloadBuf.length >= 18) {
const match16 = this.adapter.pendingRequests.get(id16);
if (match16 && match16.duid === duid) {
return match16;
}
}
// 3. Map: B01 payload starts with "B01" and exactly one pending map request for this duid => assign to it
if (payloadBuf.length >= 3 && payloadBuf.subarray(0, 3).toString("ascii") === "B01") {
const pendingMap = this.getPendingMapRequest(duid);
if (pendingMap) return pendingMap;
}
return undefined;
} catch (e: unknown) {
this.adapter.rLog("System", duid, "Warn", undefined, undefined, `Failed to extract ID from binary payload: ${this.adapter.errorMessage(e)}`, "warn");
return undefined;
}
}
}