iobroker.roborock
Version:
1,166 lines (1,014 loc) • 46.2 kB
text/typescript
// src/main.ts
/// <reference types="@iobroker/adapter-core" />
import * as utils from "@iobroker/adapter-core";
import { ChildProcess, spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import go2rtcPath from "go2rtc-static";
import { commitInfo } from "./lib/commitInfo";
// --- API & Helper Imports ---
import { AppPluginManager } from "./lib/AppPluginManager";
import { B01Variant, getB01VariantFromModel } from "./lib/b01Variant";
import { DeviceManager } from "./lib/deviceManager";
import { BaseDeviceFeatures } from "./lib/features/baseDeviceFeatures";
import type { CommandSpec } from "./lib/features/baseDeviceFeatures";
import { Feature } from "./lib/features/features.enum";
import { Device, http_api } from "./lib/httpApi";
import { local_api } from "./lib/localApi";
import { MapManager } from "./lib/map/MapManager";
import { mqtt_api } from "./lib/mqttApi";
import { PendingMapEntry, RequestPriority, RoborockRequest, requestsHandler } from "./lib/requestsHandler";
import { socketHandler } from "./lib/socketHandler";
import { TranslationManager } from "./lib/translationManager";
interface SentryPlugin {
getSentryObject(): {
captureException(error: unknown): void;
};
}
export class Roborock extends utils.Adapter {
// --- Public APIs (accessible by helpers) ---
public http_api: http_api;
public local_api: local_api;
public mqtt_api: mqtt_api;
public requestsHandler: requestsHandler;
public socketHandler!: socketHandler;
public deviceManager!: DeviceManager;
public mapManager: MapManager;
public translationManager!: TranslationManager;
// --- Internal Properties ---
public deviceFeatureHandlers: Map<string, BaseDeviceFeatures>;
public nonce: Buffer;
public pendingRequests: Map<number, RoborockRequest | PendingMapEntry>;
/** B01: FIFO queue of expected 301 map response types (classify + taskBeginDate match using this order). */
public b01MapResponseQueue: Map<string, Array<"get_map_v1" | "get_clean_record_map">> = new Map();
public appPluginManager: AppPluginManager;
public isInitializing: boolean;
public sentryInstance: SentryPlugin | undefined;
public translations: Record<string, string> = {};
private commandTimeouts: Map<string, ioBroker.Timeout> = new Map();
private mqttReconnectInterval: ioBroker.Interval | undefined = undefined;
public instance: number = 0;
private go2rtcProcess: ChildProcess | null = null;
// Bound exit handler to prevent memory leaks while allowing process.removeListener
private onExitBound: (() => void) | null = null;
constructor(options: Partial<utils.AdapterOptions> = {}) {
super({ ...options, name: "roborock", useFormatDate: true });
this.instance = options.instance || 0;
this.nonce = randomBytes(16);
this.pendingRequests = new Map();
this.http_api = new http_api(this);
this.local_api = new local_api(this);
this.mqtt_api = new mqtt_api(this);
this.requestsHandler = new requestsHandler(this);
this.mapManager = new MapManager(this);
this.translationManager = new TranslationManager(this);
this.deviceManager = new DeviceManager(this);
this.socketHandler = new socketHandler(this);
this.deviceFeatureHandlers = this.deviceManager.deviceFeatureHandlers;
this.appPluginManager = new AppPluginManager(this);
this.isInitializing = true;
this.on("ready", this.onReady.bind(this));
this.on("stateChange", this.onStateChange.bind(this));
this.on("message", this.onMessage.bind(this));
this.on("unload", this.onUnload.bind(this));
// Global Error Handlers
process.on("uncaughtException", (err) => {
this.rLog("System", null, "Error", undefined, undefined, `Uncaught Exception: ${err.message}\n${err.stack}`, "error");
});
process.on("unhandledRejection", (reason) => {
this.rLog("System", null, "Error", undefined, undefined, `Unhandled Rejection: ${reason}`, "error");
});
}
/**
* Adapter ready logic.
*/
async onReady() {
// Config properties are now type-safe thanks to types.d.ts
if (!this.config.username) {
this.rLog("System", null, "Error", undefined, undefined, "Username missing!", "error");
this.isInitializing = false;
return;
}
this.translationManager.init();
this.sentryInstance = this.getPluginInstance("sentry") as SentryPlugin | undefined;
this.translations = require(`../admin/i18n/${this.language || "en"}/translations.json`);
this.rLog("System", null, "Info", undefined, undefined, `Build Info: Date=${commitInfo.commitDate}, Commit=${commitInfo.commitHash}`, "debug");
// Log adapter settings at start (no credentials) for easier support/debugging
const safeSettings: Record<string, unknown> = {
enable_map_creation: this.config.enable_map_creation,
updateInterval: this.config.updateInterval,
region: this.config.region,
loginMethod: this.config.loginMethod,
map_theme: this.config.map_theme,
};
if ("map_creation_interval" in this.config) safeSettings.map_creation_interval = (this.config as Record<string, unknown>).map_creation_interval;
if ("map_scale" in this.config) safeSettings.map_scale = (this.config as Record<string, unknown>).map_scale;
if ("webserverPort" in this.config) safeSettings.webserverPort = (this.config as Record<string, unknown>).webserverPort;
this.rLog("System", null, "Info", undefined, undefined, `Settings: ${JSON.stringify(safeSettings)}`, "info");
// Full config for debug (credentials redacted)
const configSummary = {
...this.config,
username: this.config.username ? "******" : "NOT_SET",
password: this.config.password ? "******" : "NOT_SET",
cameraPin: this.config.cameraPin ? "******" : undefined,
};
this.rLog("System", null, "Info", undefined, undefined, `Config: ${JSON.stringify(configSummary)}`, "debug");
await this.setupBasicObjects();
try {
const clientID = await this.ensureClientID();
await this.http_api.init(clientID);
// 1. Start Cloud Data Sync (Get Keys & DUIDs)
await this.http_api.updateHomeData();
// 1b. Asset download for account models (before device init)
await this.downloadAssetsForAccountModels();
// 2a. Start UDP Discovery (Essential for determining Local/Cloud mode before Init)
await this.local_api.startUdpDiscovery();
// 2b. Start MQTT and WAIT for the connection to be established
await this.mqtt_api.init();
// --- Pre-Init Network Probe (Docker/VLAN Support) ---
this.rLog("System", null, "Info", undefined, undefined, "Starting Pre-Init Network Probe...", "debug");
const allDevices = this.http_api.getDevices() || [];
const probePromises = allDevices.map(async (device) => {
const duid = device.duid;
if (!device.online) return; // Skip devices cloud reports as offline
// If already local (UDP found it), skip
if (this.local_api.isConnected(duid)) return;
const protocolVersion = device.pv || await this.getDeviceProtocolVersion(duid);
if (protocolVersion === "B01") {
const model = this.http_api.getRobotModel(duid) || "";
if (model && getB01VariantFromModel(model) === "Q10") {
return;
}
}
try {
// 1. Get Network Info (via MQTT as we have no TCP yet)
const result = await this.requestsHandler.sendRequest(duid, "get_network_info", []);
// 2. Extract IP
let networkData: Record<string, unknown> | undefined;
if (Array.isArray(result)) {
networkData = result[0] as Record<string, unknown>;
} else if (result && typeof result === "object") {
networkData = result as Record<string, unknown>;
}
if (networkData && typeof networkData.ip === "string") {
// 3. Attempt TCP Connect with short timeout (1.5s) and silent logging
await this.local_api.checkAndPromoteLocalConnection(duid, networkData.ip, 1500, true);
}
} catch (e: unknown) {
const errorMsg = e instanceof Error ? e.message : String(e);
this.rLog("System", duid, "Debug", undefined, undefined, `Probe failed: ${errorMsg}`, "debug");
}
});
// Wait for all probes to finish (with timeout to not block forever)
await Promise.race([
Promise.all(probePromises),
new Promise(resolve => setTimeout(resolve, 2000)) // Max 2s probe time
]);
this.rLog("System", null, "Info", undefined, undefined, "Network Probe finished.", "info");
// ----------------------------------------------------
// 3. Initialize Devices (now that communication channels are ready)
await this.deviceManager.initializeDevices();
const writableFolders = new Set<string>();
for (const handler of this.deviceFeatureHandlers.values()) {
for (const folder of handler.getCommandFolders()) {
writableFolders.add(folder);
}
}
// Parallelize non-dependent startup tasks
await Promise.all([
this.processScenes(),
this.start_go2rtc(),
...Array.from(writableFolders).map((folder) => this.subscribeStatesAsync(`Devices.*.${folder}.*`)),
this.subscribeStatesAsync("Devices.*.resetConsumables.*"),
this.subscribeStatesAsync("Devices.*.programs.*"),
this.subscribeStatesAsync("Devices.*.deviceStatus.state"),
this.subscribeStatesAsync("Devices.*.deviceStatus.status"),
this.subscribeStatesAsync("loginCode")
]);
this.deviceManager.startPolling();
this.local_api.startTcpKeepaliveInterval();
this.rLog("System", null, "Info", undefined, undefined, "Adapter startup finished. Let's go!", "info");
this.isInitializing = false;
// Schedule MQTT API reset every hour (legacy behavior to prevent stale connections)
this.mqttReconnectInterval = this.setInterval(() => {
this.rLog("System", null, "Debug", undefined, undefined, "Running scheduled MQTT reconnect...", "debug");
this.resetMqttApi().catch((e: unknown) => {
this.rLog("System", null, "Error", undefined, undefined, `Scheduled MQTT reconnect failed: ${e instanceof Error ? e.message : String(e)}`, "error");
this.catchError(e, "resetMqttApi (scheduled)");
});
}, 3600 * 1000);
} catch (e: unknown) {
this.rLog("System", null, "Error", undefined, undefined, `Failed to initialize adapter: ${this.errorMessage(e)}`, "error");
this.catchError(e, "onReady");
this.isInitializing = false;
}
}
/**
* Message handler for Admin/Vis communication.
*/
async onMessage(obj: ioBroker.Message) {
if (obj && obj.command && obj.callback) {
try {
// Forward to the dedicated handler
await this.socketHandler.handleMessage(obj);
} catch (err: unknown) {
this.rLog("Requests", null, "Error", undefined, undefined, `Failed to execute command ${obj.command}: ${this.errorMessage(err)}`, "error");
this.sendTo(obj.from, obj.command, { error: this.errorMessage(err) }, obj.callback);
}
}
}
/**
* Executes a scene locally by parsing the scene definition and sending commands to the device.
*/
async executeSceneLocal(sceneId: string | number): Promise<void> {
try {
this.rLog("Requests", null, "Info", undefined, undefined, `[Scene] Executing local scene ${sceneId}`, "info");
// 1. Fetch scenes
const scenes = await this.http_api.getScenes();
if (!scenes || !scenes.result) {
this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Failed to fetch scenes or no result for ${sceneId}`, "error");
return;
}
// 2. Find target scene
// Scene ID from state might be string, API returns number. Compare loosely or convert.
const scene = scenes.result.find((s) => s.id == sceneId);
if (!scene) {
this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Scene ${sceneId} not found`, "error");
return;
}
this.rLog("Requests", null, "Debug", undefined, undefined, `[Scene] Found scene "${scene.name}"`, "debug");
// 3. Parse 'param' field
let params;
try {
params = JSON.parse(scene.param);
} catch (e: unknown) {
this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Failed to parse params for ${sceneId}: ${this.errorMessage(e)}`, "error");
return;
}
// 4. Iterate actions and execute
if (params.action && params.action.items) {
for (const item of params.action.items) {
if (item.type === "CMD") {
const targetDuid = item.entityId;
let commandPayload;
try {
commandPayload = JSON.parse(item.param);
} catch (e: unknown) {
this.rLog("Requests", targetDuid, "Error", undefined, undefined, `[Scene] Failed to parse command params for item ${item.id}: ${this.errorMessage(e)}`, "error");
continue;
}
const method = commandPayload.method;
const args = commandPayload.params;
this.rLog("Requests", targetDuid, "Info", undefined, undefined, `[Scene] Executing "${scene.name}": sending "${method}"`, "info");
// 5. Send command via requestsHandler
// We pass 'null' as handler because we are sending a raw command directly via specific method/args
// and don't need the abstraction of 'BaseDeviceFeatures' here if we go direct.
// However, requestsHandler.command expects a handler.
// Let's resolve the handler for the target Duid if possible, or cast/hack if needed.
const handler = this.deviceFeatureHandlers.get(targetDuid);
if (handler) {
await this.requestsHandler.command(handler, targetDuid, method, args);
} else {
this.rLog("Requests", targetDuid, "Warn", undefined, undefined, `[Scene] No handler found. Falling back to raw send for "${method}"`, "warn");
// Fallback: sendRequest only. Status refresh after activity-start is still triggered in resolvePendingRequest when response arrives.
await this.requestsHandler.sendRequest(targetDuid, method, args);
}
}
}
} else {
this.rLog("Requests", null, "Warn", undefined, undefined, `[Scene] Scene ${sceneId} has no actions`, "warn");
}
} catch (e: unknown) {
this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Error executing ${sceneId}: ${this.errorMessage(e)}`, "error");
}
}
/** Legacy request-based keepalive. TCP socket sessions now use localApi PINGREQ frames. */
sendTcpKeepalive(duid: string): void {
this.requestsHandler.sendRequest(duid, "get_prop", ["get_status"], { priority: RequestPriority.LOW }).catch(() => {});
}
/**
* Is called when adapter shuts down.
*/
onUnload(callback: () => void) {
try {
if (this.mqttReconnectInterval) {
this.clearInterval(this.mqttReconnectInterval);
}
this.clearTimersAndIntervals();
this.mqtt_api.cleanup();
this.local_api.stopUdpDiscovery();
this.local_api.stopTcpKeepaliveInterval();
// Remove the global process exit listener to prevent memory leaks
if (this.onExitBound) {
process.removeListener("exit", this.onExitBound);
this.onExitBound = null;
}
if (this.go2rtcProcess) {
this.rLog("Local", null, "Info", undefined, undefined, "Stopping go2rtc process...", "info");
this.go2rtcProcess.kill();
this.go2rtcProcess = null;
}
this.setState("info.connection", { val: false, ack: true });
callback();
} catch (e: unknown) {
this.rLog("System", null, "Error", undefined, undefined, `Failed to unload adapter: ${this.errorStack(e)}`, "error");
callback();
}
}
/**
* Is called if a subscribed state changes.
*/
async onStateChange(id: string, state: ioBroker.State | null | undefined) {
if (!state) return;
const idParts = id.split(".");
// deviceStatus.state (V1) or deviceStatus.status (B01): react only to our own updates (ack) — active -> idle triggers cleaning records update
if (state.ack && idParts[2] === "Devices" && idParts.length >= 6 && idParts[4] === "deviceStatus" && (idParts[5] === "state" || idParts[5] === "status")) {
const duid = idParts[3];
const newVal = state.val != null ? Number(state.val) : 0;
if (!isNaN(newVal)) {
this.deviceManager.onDeviceStateChange(duid, newVal).catch((e: unknown) => this.catchError(e, "onStateChange(deviceStatus)", duid));
}
return;
}
if (state.ack) {
if (id.endsWith(".online") && idParts.length >= 4) {
this.rLog("System", idParts[3], "Info", undefined, undefined, `Device is now ${state.val ? "online" : "offline"}`, "info");
}
return;
}
// Check for root loginCode (roborock.0.loginCode)
if (idParts[2] === "loginCode" && state.val && String(state.val).length === 6) {
this.http_api.submitLoginCode(String(state.val));
return;
}
// Devices logic
if (idParts[2] !== "Devices") return;
if (idParts.length < 6) return;
const duid = idParts[3];
const folder = idParts[4];
const command = idParts[5];
// Special handling for floors (deeply nested: Devices.duid.floors.mapFlag.load)
if (folder === "floors" && idParts.length >= 7) {
const mapFlag = parseInt(idParts[5], 10);
const target = idParts[6];
// Load Map Button
if (target === "load" && (state.val === true || state.val === "true" || state.val === 1)) {
await this.handleFloorSwitch(duid, mapFlag, id);
return;
}
}
this.rLog("Requests", duid, "Info", undefined, undefined, `[onStateChange] Processing ${folder}.${command}`, "info");
const handler = this.deviceFeatureHandlers.get(duid);
if (!handler) {
this.rLog("Requests", duid, "Warn", undefined, undefined, "[onStateChange] Received command for unknown device", "warn");
return;
}
try {
await this.handleCommand(duid, folder, command, state, handler, id);
} catch (e: unknown) {
this.catchError(e, `onStateChange (${command})`, duid);
}
}
/**
* Handles commands from onStateChange.
*/
private async handleCommand(duid: string, folder: string, command: string, state: ioBroker.State, handler: BaseDeviceFeatures, id: string) {
if (folder === "resetConsumables" && state.val === true) {
await this.requestsHandler.command(handler, duid, "reset_consumable", command, id);
// Reset button
this.setResetTimeout(id);
} else if (folder === "programs" && command === "startProgram") {
await this.executeSceneLocal(state.val as string);
this.setResetTimeout(id); // Use setResetTimeout to reset to null/empty after 1s?
// Actually executeSceneLocal takes time.
// Better: explicit reset.
await this.setState(id, { val: null, ack: true });
} else if (handler.hasCommandFolder(folder)) {
const cmdDef: CommandSpec | undefined = handler.getCommandSpec(folder, command);
if (!cmdDef) {
this.rLog("Requests", duid, "Warn", handler.protocolVersion || undefined, undefined, `[handleCommand] Ignoring unregistered command ${folder}.${command}`, "warn");
return;
}
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[handleCommand] Entering commands block for ${command}`, "info");
try {
await this.executeCommand(handler, duid, command, state, cmdDef);
} finally {
// Reset boolean command state ONLY if it is defined as boolean
const isBoolean = cmdDef.type === "boolean";
if (isBoolean && this.isTruthy(state.val)) {
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[handleCommand] Scheduling reset for ${id} (boolean)`, "info");
this.setResetTimeout(id);
}
}
}
}
/**
* Executes a specific command for a device.
*/
private async executeCommand(handler: BaseDeviceFeatures, duid: string, command: string, state: ioBroker.State, cmdDef: CommandSpec) {
const val = state.val;
// 1. Common command types handling
const isButton = cmdDef.role === "button" || cmdDef.type === "boolean";
if (isButton) {
if (this.isTruthy(val)) {
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[executeCommand] Triggering button command ${command}`, "info");
await this.requestsHandler.command(handler, duid, command);
} else {
this.rLog("Requests", duid, "Debug", handler.protocolVersion || undefined, undefined, `[executeCommand] Ignoring button command ${command} (val=${val})`, "debug");
}
return;
}
// Log start of command execution for diagnostics
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[executeCommand] Starting ${command} with params ${typeof val === "object" ? JSON.stringify(val) : val}`, "info");
// 2. Generic data commands (Numbers, Strings, JSON strings)
// We pass the raw value. getCommandParams in feature handlers will do the packaging (e.g. [val]).
if (typeof val === "string") {
const parsed = this.tryParseJson(val);
await this.requestsHandler.command(handler, duid, command, parsed !== undefined ? parsed : val);
} else {
await this.requestsHandler.command(handler, duid, command, val);
}
}
private isTruthy(val: unknown): boolean {
return val === true || val === "true" || val === 1 || val === "1";
}
/**
* Sets a timeout to reset a state to false after 1 second.
* Helps avoid race conditions by managing timeouts in a map.
*/
private setResetTimeout(id: string): void {
const timeoutKey = `${id}_reset`;
if (this.commandTimeouts.has(timeoutKey)) {
this.clearTimeout(this.commandTimeouts.get(timeoutKey)!);
}
const timeout = this.setTimeout(() => {
this.rLog("Requests", null, "Debug", undefined, undefined, `[setResetTimeout] Resetting ${id} to false`, "debug");
this.setState(id, false, true);
this.commandTimeouts.delete(timeoutKey);
}, 1000);
if (timeout) this.commandTimeouts.set(timeoutKey, timeout);
}
/**
* Ensures a ClientID exists.
*/
async ensureClientID(): Promise<string> {
try {
const clientIDState = await this.getStateAsync("clientID"); // Revert to Async
if (clientIDState?.val) {
this.rLog("System", null, "Info", undefined, undefined, `Loaded existing clientID: ${clientIDState.val}`, "info");
return clientIDState.val.toString();
}
const randomClientID = randomBytes(16).toString("hex");
await this.setState("clientID", { val: randomClientID, ack: true });
this.rLog("System", null, "Info", undefined, undefined, `Generated and saved new clientID: ${randomClientID}`, "info");
return randomClientID;
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error);
this.rLog("System", null, "Error", undefined, undefined, `Error ensuring clientID: ${errorMsg}`, "error");
throw error;
}
}
/**
* Creates base adapter objects (Folders, States).
*/
async setupBasicObjects() {
await this.setObjectNotExistsAsync("Devices", { type: "folder", common: { name: "Devices" }, native: {} });
await this.ensureState("UserData", { name: "UserData string", write: false });
await this.ensureState("HomeData", { name: "HomeData string", write: false });
await this.ensureState("clientID", { name: "Client ID", write: false });
await this.ensureState("endpoint", { name: "MQTT endpoint", write: false });
}
/** Obstacle assets for account models at startup (before device init). */
private async downloadAssetsForAccountModels(): Promise<void> {
try {
await this.http_api.ensureProductInfo();
let devices = this.http_api.getDevices() || [];
for (let wait = 0; wait < 6 && devices.length === 0; wait++) {
await new Promise((r) => setTimeout(r, 500));
devices = this.http_api.getDevices() || [];
}
const modelsInAccount = new Set<string>();
for (const d of devices) {
const m = this.http_api.getRobotModel(d.duid);
if (m && m !== "unknown" && m.includes(".")) modelsInAccount.add(m);
}
if (modelsInAccount.size === 0) return;
this.rLog("System", null, "Info", undefined, undefined, `Downloading obstacle assets for ${modelsInAccount.size} model(s)...`, "info");
await this.http_api.downloadProductImages();
for (const model of modelsInAccount) {
await this.appPluginManager.downloadAssetsForModelIfMissing(model).catch((e: unknown) => {
this.rLog("Cloud", null, "Debug", undefined, undefined, `Asset download for ${model}: ${e instanceof Error ? e.message : String(e)}`, "debug");
});
}
} catch (e: unknown) {
this.rLog("System", null, "Warn", undefined, undefined, `Obstacle asset download failed: ${e instanceof Error ? e.message : String(e)}`, "warn");
}
}
/**
* Processes scenes from HTTP API.
*/
async processScenes() {
const scenes = await this.http_api.getScenes();
if (!scenes?.result) return;
const data = scenes.result;
const programs: Record<string, Record<string, string>> = {};
for (const program of data) {
try {
const { enabled, id, name, param } = program;
const params = JSON.parse(param);
const duid = params.action.items[0].entityId;
if (!programs[duid]) programs[duid] = {};
programs[duid][id] = name;
await this.ensureFolder(`Devices.${duid}.programs`);
await this.setObjectNotExistsAsync(`Devices.${duid}.programs.${id}`, {
type: "folder",
common: { name },
native: {},
});
await this.ensureState(`Devices.${duid}.programs.${id}.enabled`, { name: "Enabled", type: "boolean" });
this.setState(`Devices.${duid}.programs.${id}.enabled`, enabled, true);
} catch (e: unknown) {
const errorMsg = e instanceof Error ? e.message : String(e);
this.rLog("Requests", null, "Warn", undefined, undefined, `[processScenes] Failed to process scene "${program.name}" (${program.id}): ${errorMsg}`, "warn");
}
}
for (const duid in programs) {
await this.ensureState(`Devices.${duid}.programs.startProgram`, {
name: "Start saved program",
type: "string",
write: true,
states: programs[duid],
});
}
}
/**
* Clears all timeouts and intervals.
*/
clearTimersAndIntervals() {
this.commandTimeouts.forEach((timeout) => this.clearTimeout(timeout));
this.commandTimeouts.clear();
this.deviceManager.stopPolling();
this.requestsHandler.clearQueue();
}
/** Timestamp keys we format as readable date string; all other keys passed through as-is. */
private static readonly DEVICE_INFO_DATE_KEYS = ["activeTime", "active_time", "createTime", "create_time"];
private static readonly DEVICE_INFO_NAME_OVERRIDES: Record<string, string> = {
activeTime: "Last Activity",
active_time: "Last Activity",
createTime: "Created At",
create_time: "Created At"
};
/**
* Updates deviceInfo from cloud HomeData: all top-level device fields are written to
* Devices.${duid}.deviceInfo.* (names unchanged). Scalars as-is; objects/arrays as JSON string.
*/
async updateDeviceInfo(duid: string, devices: Device[]) {
const device = devices.find((d) => d.duid === duid);
if (!device) return;
const raw = device as unknown as Record<string, unknown>;
for (const attr of Object.keys(raw)) {
let value: ioBroker.StateValue = raw[attr] as ioBroker.StateValue;
if (typeof value === "object" && value !== null) {
value = JSON.stringify(value);
}
const common: Partial<ioBroker.StateCommon> = {};
let finalValue: ioBroker.StateValue = value;
if (Roborock.DEVICE_INFO_NAME_OVERRIDES[attr]) {
common.name = Roborock.DEVICE_INFO_NAME_OVERRIDES[attr];
}
if (Roborock.DEVICE_INFO_DATE_KEYS.includes(attr) && typeof value === "number") {
finalValue = this.formatRoborockDate(value);
common.type = "string";
} else {
common.type = typeof finalValue as ioBroker.CommonType;
}
await this.ensureState(`Devices.${duid}.deviceInfo.${attr}`, common);
await this.setStateChanged(`Devices.${duid}.deviceInfo.${attr}`, { val: finalValue, ack: true });
}
}
/**
* Checks for new firmware.
*/
async checkForNewFirmware(duid: string) {
const isLocal = this.local_api.isLocalDevice(duid);
if (!isLocal) return;
try {
this.rLog("HTTP", duid, "Debug", undefined, undefined, "[checkForNewFirmware] Checking for firmware update...", "debug");
const update = await this.http_api.getFirmwareStates(duid);
this.rLog("HTTP", duid, "Debug", undefined, undefined, `[checkForNewFirmware] Result: ${JSON.stringify(update)}`, "debug");
if (update.data.result) {
for (const state in update.data.result) {
const value = update.data.result[state];
await this.ensureState(`Devices.${duid}.updateStatus.${state}`, { type: typeof value as ioBroker.CommonType });
await this.setStateChanged(`Devices.${duid}.updateStatus.${state}`, { val: value, ack: true });
}
} else {
this.rLog("HTTP", duid, "Warn", undefined, undefined, "[checkForNewFirmware] No result in firmware update response", "warn");
}
} catch (error: unknown) {
this.rLog("HTTP", duid, "Warn", undefined, undefined, `Failed to check for new firmware: ${this.errorMessage(error)}`, "warn");
}
}
/**
* Creates a state if it doesn't exist, applying translations.
*/
public async ensureState(path: string, commonOptions: Partial<ioBroker.StateCommon>, native: Record<string, unknown> = {}) {
const stateName = path.split(".").pop() || path;
// Allow empty string as name if explicitly provided. Only use fallback if name is undefined.
const translatedName = commonOptions.name !== undefined ? commonOptions.name : (this.translations[stateName] || stateName);
const baseCommon: ioBroker.StateCommon = {
name: translatedName,
type: "string",
role: "value",
read: true,
write: false,
};
const finalCommon = { ...baseCommon, ...commonOptions, name: translatedName };
if (finalCommon.def === undefined || finalCommon.def === null || finalCommon.def === "") {
delete finalCommon.def;
}
let oldObj: ioBroker.Object | null | undefined;
try {
oldObj = await this.getObjectAsync(path);
} catch {
oldObj = null; // Does not exist
}
// Check if object exists AND if its metadata is different from what we need
if (oldObj && !this.hasCommonChanged(oldObj.common as ioBroker.StateCommon, finalCommon)) {
return;
}
try {
if (oldObj) {
// Object exists, but metadata changed
// Safely merge common properties
const newCommon = { ...oldObj.common, ...finalCommon };
// Force extension to apply changes
await this.extendObject(path, { common: newCommon });
} else {
// Object does not exist, create it new.
// Provide mandatory defaults for a valid ioBroker state object.
const defaults: Partial<ioBroker.StateCommon> = {
role: "state",
read: true,
write: false,
type: "mixed"
};
const commonObj: ioBroker.StateCommon = { ...defaults, ...finalCommon } as ioBroker.StateCommon;
if (!commonObj.type) commonObj.type = "mixed";
await this.setObject(path, {
type: "state",
common: commonObj,
native: native,
});
}
} catch (e: unknown) {
this.rLog("System", null, "Error", undefined, undefined, `[ensureState] Failed to update/create object for "${path}": ${this.errorMessage(e)}`, "error");
}
}
/**
* Helper to check if common properties of an object have meaningfully changed.
*
* PERFORMANCE CRITICAL:
* This method prevents "Write Storms" to the ioBroker database (objects.json/redis).
* Writing objects is expensive (disk I/O) and triggers system-wide events.
* We only write if the definition (name, role, unit, etc.) has actually changed.
* This significantly reduces CPU usage and disk wear on startup.
*/
private hasCommonChanged(oldCommon: ioBroker.StateCommon, newCommon: Partial<ioBroker.StateCommon>): boolean {
if (newCommon.type !== undefined && oldCommon.type !== newCommon.type) return true;
if (newCommon.name !== undefined && this.stringifySorted(oldCommon.name) !== this.stringifySorted(newCommon.name)) return true;
if (newCommon.states !== undefined && this.stringifySorted(oldCommon.states) !== this.stringifySorted(newCommon.states)) return true;
if (newCommon.role !== undefined && oldCommon.role !== newCommon.role) return true;
if (newCommon.unit !== undefined && oldCommon.unit !== newCommon.unit) return true;
if (newCommon.min !== undefined && oldCommon.min !== newCommon.min) return true;
if (newCommon.max !== undefined && oldCommon.max !== newCommon.max) return true;
if (newCommon.icon !== undefined && oldCommon.icon !== newCommon.icon) return true;
if (newCommon.read !== undefined && oldCommon.read !== newCommon.read) return true;
if (newCommon.write !== undefined && oldCommon.write !== newCommon.write) return true;
if (newCommon.def !== undefined && oldCommon.def !== newCommon.def) return true;
return false;
}
/**
* JSON.stringify with sorted keys for consistent object comparison.
*/
private stringifySorted(obj: unknown): string {
return JSON.stringify(obj, (_key, value) => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce((sorted: Record<string, unknown>, key) => {
sorted[key] = (value as Record<string, unknown>)[key];
return sorted;
}, {});
}
return value;
});
}
/**
* Safe string from any thrown value (message if Error, else String(e)).
* Use in catch (e: unknown) instead of repeating e instanceof Error ? e.message : String(e).
*/
public errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* Stack trace if Error, else message, else String(e).
*/
public errorStack(e: unknown): string {
if (e instanceof Error) return e.stack ?? e.message;
return String(e);
}
/**
* Helper to format Roborock timestamps (seconds) to locale string.
*/
public formatRoborockDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString();
}
/**
* Helper to safely parse JSON strings that look like objects/arrays.
*/
private tryParseJson(value: string): unknown | undefined {
const trimmed = value.trim();
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && (trimmed.endsWith("}") || trimmed.endsWith("]"))) {
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
return undefined;
}
/**
* Creates a folder if it doesn't exist, applying translations.
*/
async ensureFolder(path: string, customName?: string | ioBroker.StringOrTranslated) {
const attribute = path.split(".").pop() || path;
const name = customName || this.translations[attribute] || attribute;
let oldObj: ioBroker.Object | null | undefined;
try {
oldObj = await this.getObjectAsync(path);
} catch {
oldObj = null; // Does not exist
}
if (!oldObj || oldObj.type !== "folder") {
await this.setObject(path, {
type: "folder",
common: {
name: name
},
native: {}
});
} else if (customName !== undefined) {
// Only update name when explicitly passed; avoid overwriting with path segment when ensuring existence (issue #1140)
const currentName = oldObj.common.name;
const isDifferent = JSON.stringify(currentName) !== JSON.stringify(name);
if (isDifferent) {
try {
await this.extendObject(path, { common: { name } });
} catch (e: unknown) {
this.rLog("System", null, "Error", undefined, undefined, `Failed to update folder name for ${path}: ${this.errorMessage(e)}`, "error");
}
}
}
}
/**
* Gets the protocol version for a device.
*/
async getDeviceProtocolVersion(duid: string): Promise<string> {
const tcpConnected = this.local_api.isConnected(duid);
if (tcpConnected) {
const localPv = this.local_api.getLocalProtocolVersion(duid);
if (localPv) return localPv;
}
const devices = this.http_api.getDevices();
const device = devices ? devices.find((d) => d.duid == duid) : undefined;
return device?.pv || "1.0";
}
/**
* Returns the B01 sub-variant for a device when applicable.
* Q10 behaves event-driven and is routed separately from classic B01/Q7.
*/
async getB01Variant(duid: string): Promise<B01Variant | null> {
const handler = this.deviceFeatureHandlers.get(duid);
if (handler && "b01Variant" in handler && typeof (handler as { b01Variant?: unknown }).b01Variant === "string") {
return (handler as { b01Variant: B01Variant }).b01Variant;
}
const pv = await this.getDeviceProtocolVersion(duid);
if (pv !== "B01") return null;
const model = this.http_api.getRobotModel(duid);
return model ? getB01VariantFromModel(model) : "Q7";
}
/**
* Starts the go2rtc process if cameras are present.
*/
async start_go2rtc() {
const devices = this.http_api.getDevices() || [];
const localKeys = this.http_api.getMatchedLocalKeys();
const { u, s, k } = this.http_api.get_rriot();
const apiPort = 1984 + this.instance; // API/Web Port
const rtspPort = 8554 + this.instance; // RTSP Port
const go2rtcConfig = {
server: { listen: `:${apiPort}` },
rtsp: { listen: `:${rtspPort}` },
streams: {} as Record<string, string>,
};
let cameraCount = 0;
for (const device of devices) {
const duid = device.duid;
const handler = this.deviceFeatureHandlers.get(duid);
const localKey = localKeys.get(duid);
if (handler && localKey && handler.hasStaticFeature(Feature.Camera)) {
cameraCount++;
go2rtcConfig.streams[duid] = `roborock://mqtt-eu-3.roborock.com:8883?u=${u}&s=${s}&k=${k}&did=${duid}&key=${localKey}&pin=${this.config.cameraPin}`;
}
}
if (cameraCount > 0 && go2rtcPath) {
try {
this.go2rtcProcess = spawn(go2rtcPath.toString(), ["-config", JSON.stringify(go2rtcConfig)], { shell: false, detached: false, windowsHide: true });
this.go2rtcProcess!.on("error", (err) => this.rLog("Local", null, "Error", undefined, undefined, `go2rtc start error: ${err.message}`, "error"));
this.go2rtcProcess!.stdout!.on("data", (data) => this.rLog("Local", null, "Debug", undefined, undefined, `go2rtc output: ${data.toString().trim()}`, "debug"));
this.go2rtcProcess!.stderr!.on("data", (data) => {
const msg = data.toString().trim();
const isShutdown = /signal:\s*terminated|exit with signal/i.test(msg);
this.rLog("Local", null, isShutdown ? "Info" : "Error", undefined, undefined, `go2rtc ${isShutdown ? "output" : "error output"}: ${msg}`, isShutdown ? "info" : "error");
});
// Remove the process reference on exit to prevent double-kill attempts
this.go2rtcProcess!.on("exit", () => {
this.go2rtcProcess = null;
});
// Safety net: Ensure child process ensures if Node.js crashes/exits
this.onExitBound = () => {
if (this.go2rtcProcess) {
this.go2rtcProcess.kill();
}
};
process.on("exit", this.onExitBound);
} catch (error: unknown) {
this.rLog("Local", null, "Error", undefined, undefined, `Failed to spawn go2rtc: ${this.errorMessage(error)}`, "error");
}
}
}
/**
* Processes A01 (Tuya) protocol messages.
*/
async processA01(duid: string, response: { dps?: Record<string, unknown> }): Promise<void> {
if (!response?.dps) {
this.rLog("Local", duid, "Warn", "A01", undefined, `Invalid response: ${JSON.stringify(response)}`, "warn");
return;
}
const determineType = (value: unknown): ioBroker.CommonType => {
const t = typeof value;
if (t === "number") return "number";
if (t === "boolean") return "boolean";
if (t === "object" && value !== null) return "object";
return "string";
};
// Recursive helper for nested JSON objects
const processNested = async (basePath: string, obj: Record<string, unknown>) => {
for (const [key, value] of Object.entries(obj)) {
const path = `${basePath}.${key}`;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
await this.ensureFolder(path);
await processNested(path, value as Record<string, unknown>);
} else {
const val = typeof value === "object" || value === null ? JSON.stringify(value) : (value as ioBroker.StateValue);
await this.ensureState(path, { name: key, type: determineType(value), write: false });
await this.setStateChanged(path, { val, ack: true });
}
}
};
for (const [id, value] of Object.entries(response.dps)) {
// A01 states are not defined in main.ts anymore, this is just a fallback name
const stateName = id;
let parsedValue = value;
let isJson = false;
if (typeof value === "object" && value !== null) {
parsedValue = value;
isJson = true;
} else if (typeof value === "string") {
const maybeJson = this.tryParseJson(value);
if (maybeJson !== undefined) {
parsedValue = maybeJson;
isJson = true;
}
}
if (isJson && typeof parsedValue === "object" && parsedValue !== null) {
const basePath = `Devices.${duid}.${id}`; // Use ID as folder name
await this.ensureFolder(basePath);
await processNested(basePath, parsedValue as Record<string, unknown>);
} else {
const path = `Devices.${duid}.deviceStatus.${id}`;
await this.ensureState(path, { name: stateName, type: determineType(value), write: false });
await this.setStateChanged(path, { val: parsedValue as ioBroker.StateValue, ack: true });
}
}
}
/**
* Resets the MQTT API instance.
*/
async resetMqttApi() {
this.rLog("System", null, "Info", undefined, undefined, "Resetting MQTT API instance...", "info");
if (this.mqtt_api) {
this.mqtt_api.cleanup();
this.requestsHandler.clearQueue(); // Prevents pending promises
}
// Create a new MQTT API instance and initialize it
this.mqtt_api = new mqtt_api(this);
await this.mqtt_api.init();
this.rLog("System", null, "Info", undefined, undefined, "MQTT API instance has been reset.", "info");
}
/**
* Centralized error handler.
*/
async catchError(error: unknown, attribute?: string, duid?: string) {
const robotModel = duid ? this.http_api.getRobotModel(duid) : "unknown";
const stack = this.errorStack(error);
const errorMsg = this.errorMessage(error);
const msg = `Failed processing ${attribute || "task"} on ${duid || "adapter"} (${robotModel}): ${stack}`;
if (errorMsg.includes("retry") || errorMsg.includes("locating") || errorMsg.includes("timed out")) {
this.rLog("System", duid, "Warn", undefined, undefined, msg, "warn");
} else {
this.rLog("System", duid, "Error", undefined, undefined, msg, "error");
if (this.sentryInstance) {
this.sentryInstance.getSentryObject().captureException(error);
}
}
}
/**
* Centralized Logging Function for Protocol Messages
* Format: [Connection] [duid] direction [version] [protocol] [ID: id] | payload
*/
rLog(connection: "MQTT" | "TCP" | "UDP" | "HTTP" | "Cloud" | "Local" | "System" | "MapManager" | "Requests" | "Unknown", duid: string | null | undefined, direction: "<-" | "->" | "Info" | "Error" | "Warn" | "Debug", version: string | undefined, protocol: string | number | undefined, message: string, level: "debug" | "info" | "warn" | "error" = "debug", msgId?: string | number): void {
// Use == as a neutral placeholder for alignment if it's not actual traffic (<- or ->).
const directionDisplay = (direction === "<-" || direction === "->") ? direction : "==";
// Construct prefix and message body using parts to ensure clean spacing.
const parts = [directionDisplay, `[${connection}]`];
if (duid) parts.push(`[${duid}]`);
if (version) parts.push(`[${version}]`);
if (protocol) parts.push(`[${protocol}]`);
if (msgId !== undefined) parts.push(`[ID: ${msgId}]`);
const logMsg = `${parts.join(" ")} | ${message}`;
switch (level) {
case "debug":
this.log.debug(logMsg);
break;
case "info":
this.log.info(logMsg);
break;
case "warn":
this.log.warn(logMsg);
break;
case "error":
this.log.error(logMsg);
break;
}
}
// Helper to handle floor switching logic (extracted to reduce nesting)
async handleFloorSwitch(duid: string, mapFlag: number, stateId: string): Promise<void> {
const handler = this.deviceFeatureHandlers.get(duid);
if (!handler) return;
try {
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Loading map ${mapFlag}`, "info");
// 1. Send load command and wait for robot ACK
await this.requestsHandler.sendRequest(duid, "load_multi_map", [mapFlag], { timeout: 60000 });
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, "[floorSwitch] Load acknowledged, verifying map index sync", "info");
// Failsafe: Robot says "ok" but might need a few seconds to switch currentMapIndex
const startTime = Date.now();
let verified = false;
for (let i = 0; i < 10; i++) {
await handler.updateStatus();
const currentIndex = handler.getCurrentMapIndex();
// Use exposed method if available or cast to any to access internal if needed (assuming logic added to V1Feature)
// For now relying on public interface which delegates to V1MapService
const rawStatus = (handler as any).mapService ? (handler as any).mapService.lastMapStatus : -1;
const elapsed = Date.now() - startTime;
// Verify using both index match and verifying raw status supports it
if (currentIndex === mapFlag) {
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Synced map index to ${currentIndex} (status=${rawStatus}, attempt=${i + 1}/10, elapsed=${elapsed}ms)`, "info");
verified = true;
break;
}
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Waiting for sync (current=${currentIndex}, target=${mapFlag}, status=${rawStatus}, attempt=${i + 1}/10, elapsed=${elapsed}ms)`, "info");
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (!verified) {
this.rLog("Requests", duid, "Warn", handler.protocolVersion || undefined, undefined, `[floorSwitch] Map index did not sync to ${mapFlag} after retries; proceeding`, "warn");
}
await handler.updateMultiMapsList();
await handler.updateRoomMapping();
await handler.updateMap();
this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Completed switch to map ${mapFlag}`, "info");
} catch (e: unknown) {
this.catchError(e, "floorSwitch", duid);
} finally {
// Reset button
this.setResetTimeout(stateId);
}
}
}
if (require.main !== module) {
// Export the constructor in compact mode
module.exports = (options: Partial<utils.AdapterOptions>) => new Roborock(options);
} else {
// otherwise start the instance directly
new Roborock();
}