UNPKG

iobroker.roborock

Version:
526 lines (455 loc) 21.6 kB
// src/lib/DeviceManager.ts import type { Roborock } from "../main"; // Import BaseDeviceFeatures value import { BaseDeviceFeatures, FeatureDependencies } from "./features/baseDeviceFeatures"; import { FallbackBaseFeatures, FallbackVacuumFeatures } from "./features/fallbackFeatures"; import { DEFAULT_PROFILE, VacuumProfile } from "./features/vacuum/v1VacuumFeatures"; import { ProductHelper } from "./productHelper"; import { Feature } from "./features/features.enum"; import { getB01VariantFromModel } from "./b01Variant"; import { isB01ParkedState } from "./map/b01/B01StateSemantics"; // Import indices to trigger decorators import "./features/vacuum/index"; import { Q7VacuumFeatures } from "./features/vacuum/b01/Q7VacuumFeatures"; import { Q10VacuumFeatures } from "./features/vacuum/b01/Q10VacuumFeatures"; function createFeaturesForModel(adapter: Roborock, duid: string, robotModel: string, productCategory: string | null, protocolVersion: string | null): BaseDeviceFeatures { const dependencies: FeatureDependencies = { adapter: adapter, config: adapter.config, http_api: adapter.http_api, ensureState: adapter.ensureState.bind(adapter), ensureFolder: adapter.ensureFolder.bind(adapter), log: adapter.log, }; // dynamic profile creation let dynamicProfile: VacuumProfile = DEFAULT_PROFILE; const productInfo = adapter.http_api.productInfo; if (productInfo) { const fanMappings = ProductHelper.getStateDefinitions(productInfo, robotModel, "fan_power"); const mopMappings = ProductHelper.getStateDefinitions(productInfo, robotModel, "mop_mode"); const waterMappings = ProductHelper.getStateDefinitions(productInfo, robotModel, "water_box_mode"); const errorMappings = ProductHelper.getStateDefinitions(productInfo, robotModel, "error"); const stateMappings = ProductHelper.getStateDefinitions(productInfo, robotModel, "state"); if (Object.keys(fanMappings).length > 0 || Object.keys(mopMappings).length > 0 || Object.keys(waterMappings).length > 0 || Object.keys(errorMappings).length > 0 || Object.keys(stateMappings).length > 0) { dynamicProfile = { ...DEFAULT_PROFILE, mappings: { fan_power: Object.keys(fanMappings).length > 0 ? fanMappings : DEFAULT_PROFILE.mappings.fan_power, mop_mode: Object.keys(mopMappings).length > 0 ? mopMappings : DEFAULT_PROFILE.mappings.mop_mode, water_box_mode: Object.keys(waterMappings).length > 0 ? waterMappings : DEFAULT_PROFILE.mappings.water_box_mode, error_code: Object.keys(errorMappings).length > 0 ? errorMappings : undefined, state: Object.keys(stateMappings).length > 0 ? stateMappings : undefined, } }; } } // B01 Detection: Prioritize Protocol Version over Registered Model Class // This ensures that B01 devices always get the B01 feature handler, even if they share a model ID with a V1 device. if (protocolVersion === "B01") { const b01Variant = getB01VariantFromModel(robotModel); const HandlerClass = b01Variant === "Q10" ? Q10VacuumFeatures : Q7VacuumFeatures; const handler = new HandlerClass(dependencies, duid, robotModel, { staticFeatures: [] }, dynamicProfile); handler.protocolVersion = protocolVersion; return handler; } // Get registered model class (optional: only for model-specific overrides) const ModelClass = BaseDeviceFeatures.getRegisteredModelClass(robotModel); if (ModelClass) { // Specific model class registered – use it (e.g. custom profile/features) const handler = new ModelClass(dependencies, duid); handler.protocolVersion = protocolVersion; return handler; } // No model-specific class: auto-detect by category. Log once so users report unknown models for full support. const isVacuum = productCategory === "robot.vacuum.cleaner" || productCategory === "roborock.vacuum"; if (isVacuum) { adapter.rLog("System", duid, "Info", undefined, undefined, `Model "${robotModel}" is not explicitly supported yet; using auto-detected vacuum features. If something is missing or wrong, please report your model (e.g. via GitHub Issues) so we can add full support with correct parameters.`, "info"); const deducedFeatures = productInfo ? ProductHelper.deduceFeatures(productInfo, robotModel) : new Set<Feature>(); const handler = new FallbackVacuumFeatures(dependencies, duid, robotModel, dynamicProfile, { staticFeatures: Array.from(deducedFeatures), autoDetected: true }); handler.protocolVersion = protocolVersion; return handler; } // Unknown category: warn and use generic fallback adapter.rLog("System", duid, "Warn", undefined, undefined, `Model "${robotModel}" (Category: ${productCategory}) not registered. Using fallback (Protocol: ${protocolVersion || "Unknown"}).`, "warn"); const handler = new FallbackBaseFeatures(dependencies, duid, robotModel); handler.protocolVersion = protocolVersion; return handler; } export class DeviceManager { private adapter: Roborock; // Interval handle private mainUpdateInterval: ioBroker.Interval | undefined = undefined; private pollingDevices = new Set<string>(); public deviceFeatureHandlers = new Map<string, BaseDeviceFeatures>(); constructor(adapter: Roborock) { this.adapter = adapter; } /** * Initializes devices from HTTP API. */ public async initializeDevices(): Promise<void> { const devices = this.adapter.http_api.getDevices(); this.adapter.rLog("System", null, "Info", undefined, undefined, `Initializing data for ${devices.length} devices...`, "info"); const initPromises: Promise<void>[] = []; const cleanSummaryHandlers: BaseDeviceFeatures[] = []; for (const device of devices) { const duid = device.duid; const initTask = async () => { try { const model = this.adapter.http_api.getRobotModel(duid); const category = this.adapter.http_api.getProductCategory(duid); // Ensure model exists if (!model) { this.adapter.rLog("System", duid, "Warn", undefined, undefined, "Could not find model. Skipping init.", "warn"); return; } const version = await this.adapter.getDeviceProtocolVersion(duid); const handler = createFeaturesForModel(this.adapter, duid, model, category, version); // Store handler and initialize this.deviceFeatureHandlers.set(duid, handler); await this.adapter.extendObject(`Devices.${duid}`, { type: "device", common: { name: device.name || duid, // Use cloud name or DUID // Link online status statusStates: { onlineId: `${this.adapter.namespace}.Devices.${duid}.deviceInfo.online`, }, }, native: { duid: duid, model: model, category: category, }, }); await this.adapter.updateDeviceInfo(duid, devices); // Apply static features await handler.initialize(device.online); if (device.online) { // Fire cleaning summary (background) cleanSummaryHandlers.push(handler); } } catch (error: unknown) { this.adapter.rLog("System", duid, "Warn", undefined, undefined, `Failed initial poll: ${this.adapter.errorMessage(error)}`, "warn"); } }; initPromises.push(initTask()); } await Promise.all(initPromises); const deviceSummaries: string[] = []; for (const [duid, handler] of this.deviceFeatureHandlers) { const model = (handler as any).robotModel || "Unknown"; const version = (handler as any).protocolVersion || "Unknown"; deviceSummaries.push(`${duid} (${model}, ${version})`); } this.adapter.rLog("System", null, "Info", undefined, undefined, `Initialization complete for ${this.deviceFeatureHandlers.size} devices: [${deviceSummaries.join(", ")}]`, "info"); // Fire cleaning summary (background) for (const handler of cleanSummaryHandlers) { handler.updateCleanSummary().catch((e: unknown) => { this.adapter.rLog("System", (handler as any).duid, "Warn", undefined, undefined, `Background summary update failed: ${this.adapter.errorMessage(e)}`, "warn"); }); } // Cleanup orphaned devices (non-blocking) this.cleanupOrphanedDevices(devices.map((d) => d.duid)).catch((e: unknown) => { this.adapter.rLog("System", null, "Warn", undefined, undefined, `Device cleanup failed: ${this.adapter.errorMessage(e)}`, "warn"); }); } /** * Removes orphaned device folders. */ private async cleanupOrphanedDevices(activeDuids: string[]): Promise<void> { const activeDuidSet = new Set(activeDuids); const namespace = this.adapter.namespace; try { // Get all device objects (more efficient than getting all objects) const devices = await this.adapter.getDevicesAsync(); const deviceFolders = devices.map((obj) => obj._id).filter((id) => id.startsWith(`${namespace}.Devices.`) && id.split(".").length === 4); // Safety guard: NEVER delete all devices at once. // This covers the case where the cloud returns an empty list incorrectly. if (activeDuids.length === 0 && deviceFolders.length > 0) { this.adapter.rLog("System", null, "Warn", undefined, undefined, "Cleanup of orphaned devices blocked: API returned 0 devices, but local states exist. Mass deletion prevented.", "warn"); return; } for (const folderId of deviceFolders) { const duid = folderId.split(".").pop(); if (duid && !activeDuidSet.has(duid)) { this.adapter.rLog("System", duid, "Info", undefined, undefined, `Deleting orphaned device folder: ${folderId}`, "info"); await this.adapter.delObjectAsync(folderId, { recursive: true }); } } } catch (error: unknown) { this.adapter.rLog("System", null, "Error", undefined, undefined, `Failed to cleanup orphaned devices: ${this.adapter.errorMessage(error)}`, "error"); } } // Track previous state private lastStateCode = new Map<string, number>(); private skipPollUntilNextHomeData = new Set<string>(); // cleared each slow tick /** * Get current state code. V1 uses deviceStatus.state, B01 uses deviceStatus.status. */ private async getDeviceState(duid: string): Promise<number> { const [state, status] = await Promise.all([ this.adapter.getStateAsync(`Devices.${duid}.deviceStatus.state`), this.adapter.getStateAsync(`Devices.${duid}.deviceStatus.status`), ]); const val = (state?.val != null ? state.val : status?.val) ?? 0; return Number(val); } /** * Check if robot is active. */ private isActiveState(stateCode: number): boolean { const activeStates = [ 5, // Cleaning 6, // Returning Dock 7, // Manual Mode 11, // Spot Cleaning 15, // Docking 16, // Go To 17, // Zone Clean 18, // Room Clean 22, // Emptying dust container 23, // Washing the mop 26, // Going to wash the mop 29, // Mapping ]; return activeStates.includes(stateCode); } /** Parked/docked: fetch history only then (cloud has new record). 4 = docked, 8 = Charging, 100 = Fully Charged. */ private isParkedState(stateCode: number): boolean { return isB01ParkedState(stateCode); } /** Starts polling. updateInterval (UI) drives everything except TCP; TCP keepalive is fixed 30s. */ public startPolling(): void { const mainPollInterval = this.adapter.config.updateInterval; // e.g. 60s this.adapter.rLog("System", null, "Info", undefined, undefined, `Starting main poll (every ${mainPollInterval}s). Heavy data updates only after activity finishes.`, "info"); let mainUpdateCount = mainPollInterval; // Slow loop counter this.mainUpdateInterval = this.adapter.setInterval(async () => { mainUpdateCount++; const isSlowTick = mainUpdateCount >= mainPollInterval; if (isSlowTick) { mainUpdateCount = 0; this.skipPollUntilNextHomeData.clear(); this.adapter.rLog("System", null, "Debug", undefined, undefined, "Running scheduled main device update...", "debug"); await this.adapter.http_api.updateHomeData(); void this.adapter.local_api?.refreshStaleLocalEndpoints?.("slow poll")?.catch((e: unknown) => { this.adapter.rLog("TCP", null, "Debug", undefined, undefined, `Scheduled local endpoint refresh failed: ${this.adapter.errorMessage(e)}`, "debug"); }); } const cloudDevices = this.adapter.http_api.getDevices(); for (const device of cloudDevices) { const duid = device.duid; if (this.skipPollUntilNextHomeData.has(duid)) continue; const handler = this.deviceFeatureHandlers.get(duid); if (!handler) continue; const lastState = this.lastStateCode.get(duid) || 0; const isActive = this.isActiveState(lastState); const isFastTick = (mainUpdateCount % 2 === 0); const shouldPoll = isSlowTick || (isActive && isFastTick); if (!shouldPoll) continue; try { if (isSlowTick) { await this.adapter.updateDeviceInfo(duid, cloudDevices); } if (!device.online) continue; const version = await this.adapter.getDeviceProtocolVersion(duid); if (this.pollingDevices.has(duid)) { this.adapter.rLog("System", duid, "Debug", version, undefined, "Skipping poll because previous poll is still running.", "debug"); continue; } this.pollingDevices.add(duid); try { switch (version) { case "B01": await this.pollB01Device(handler, duid); break; case "A01": await this.pollA01Device(handler, duid); break; case "L01": case "1.0": await this.pollV1Device(handler, duid); break; default: this.adapter.rLog("System", duid, "Warn", version, undefined, "Unknown protocol version. Skipping poll.", "warn"); } } finally { this.pollingDevices.delete(duid); } } catch (error: unknown) { this.adapter.catchError(error, "mainUpdateInterval", duid); this.skipPollUntilNextHomeData.add(duid); } } }, 1000); // 1s ticker } /** On state change: history only when parked (4/8/100) or idle→parked (3→4); else only map. */ public async onDeviceStateChange(duid: string, newStateVal: number): Promise<void> { const handler = this.deviceFeatureHandlers.get(duid); if (!handler) return; const oldVal = this.lastStateCode.get(duid) ?? 0; const wasActive = this.isActiveState(oldVal); const isActive = this.isActiveState(newStateVal); const isParked = this.isParkedState(newStateVal); const wasIdle = oldVal === 3; this.lastStateCode.set(duid, newStateVal); if (wasActive && !isActive) { const version = (handler as any).protocolVersion || "?"; if (isParked) { this.adapter.rLog("System", duid, "Info", version, undefined, `Activity finished (State ${oldVal} -> ${newStateVal}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } else { this.adapter.rLog("System", duid, "Info", version, undefined, `Activity finished (State ${oldVal} -> ${newStateVal}). Fetching map...`, "info"); await handler.updateMap(); } } else if (wasIdle && isParked) { const version = (handler as any).protocolVersion || "?"; this.adapter.rLog("System", duid, "Info", version, undefined, `Idle -> Parked (State ${oldVal} -> ${newStateVal}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } } /** * Polling logic for B01 devices. */ private async pollB01Device(handler: BaseDeviceFeatures, duid: string): Promise<void> { await handler.updateStatus(); const currentState = await this.getDeviceState(duid); const lastState = this.lastStateCode.get(duid) || 0; const isActive = this.isActiveState(currentState); const wasActive = this.isActiveState(lastState); if (isActive) { await handler.updateMap(); } if (wasActive && !isActive) { const isParked = this.isParkedState(currentState); if (isParked) { this.adapter.rLog("System", duid, "Info", "B01", undefined, `Activity finished (State ${lastState} -> ${currentState}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } else { this.adapter.rLog("System", duid, "Info", "B01", undefined, `Activity finished (State ${lastState} -> ${currentState}). Fetching map...`, "info"); await handler.updateMap(); } } else if (lastState === 3 && this.isParkedState(currentState)) { this.adapter.rLog("System", duid, "Info", "B01", undefined, `Idle -> Parked (State ${lastState} -> ${currentState}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } this.lastStateCode.set(duid, currentState); } /** * Polling logic for A01 devices. */ private async pollA01Device(handler: BaseDeviceFeatures, duid: string): Promise<void> { await handler.updateStatus(); const currentState = await this.getDeviceState(duid); const lastState = this.lastStateCode.get(duid) || 0; const isActive = this.isActiveState(currentState); const wasActive = this.isActiveState(lastState); if (isActive) { await handler.updateMap(); } if (wasActive && !isActive) { const isParked = this.isParkedState(currentState); if (isParked) { this.adapter.rLog("System", duid, "Info", "A01", undefined, `Activity finished (State ${lastState} -> ${currentState}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } else { this.adapter.rLog("System", duid, "Info", "A01", undefined, `Activity finished (State ${lastState} -> ${currentState}). Fetching map...`, "info"); await handler.updateMap(); } } else if (lastState === 3 && this.isParkedState(currentState)) { this.adapter.rLog("System", duid, "Info", "A01", undefined, `Idle -> Parked (State ${lastState} -> ${currentState}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } this.lastStateCode.set(duid, currentState); } /** * Polling logic for V1 (Legacy) devices. */ private async pollV1Device(handler: BaseDeviceFeatures, duid: string): Promise<void> { await handler.updateStatus(); const currentState = await this.getDeviceState(duid); const lastState = this.lastStateCode.get(duid) || 0; const isActive = this.isActiveState(currentState); const wasActive = this.isActiveState(lastState); if (isActive) { await handler.updateMap(); } if (wasActive && !isActive) { const isParked = this.isParkedState(currentState); if (isParked) { this.adapter.rLog("System", duid, "Info", "1.0", undefined, `Activity finished (State ${lastState} -> ${currentState}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } else { this.adapter.rLog("System", duid, "Info", "1.0", undefined, `Activity finished (State ${lastState} -> ${currentState}). Fetching map...`, "info"); await handler.updateMap(); } } else if (lastState === 3 && this.isParkedState(currentState)) { this.adapter.rLog("System", duid, "Info", "1.0", undefined, `Idle -> Parked (State ${lastState} -> ${currentState}). Fetching history + map...`, "info"); await handler.updateMap(); await handler.updateCleanSummary().catch((e: unknown) => this.adapter.catchError(e, "updateCleanSummary", duid)); } this.lastStateCode.set(duid, currentState); } /** * Stops polling. */ public stopPolling(): void { if (this.mainUpdateInterval) { // Cast to any for ioBroker interval this.adapter.clearInterval(this.mainUpdateInterval as any); this.mainUpdateInterval = undefined; } } /** * Fetches non-status data. */ public async updateDeviceData(handler: BaseDeviceFeatures, duid: string): Promise<void> { await Promise.all([ handler.updateFirmwareFeatures(), handler.updateMultiMapsList(), handler.updateRoomMapping(), handler.updateConsumables(), handler.updateTimers(), handler.updateNetworkInfo(), ]); await this.adapter.checkForNewFirmware(duid); // Model-specific requests await handler.updateExtraStatus(); } /** * Fetches consumable percentages. */ public async updateConsumablesPercent(duid: string): Promise<void> { const handler = this.deviceFeatureHandlers.get(duid); if (!handler) return; const devices = this.adapter.http_api.getDevices(); const device = devices.find((d) => d.duid === duid); if (!device?.deviceStatus) return; // 'deviceStatus' exists on Device type const status = device.deviceStatus as Record<string, number>; const consumableMap: Record<string, string> = { "125": "main_brush_life", "126": "side_brush_life", "127": "filter_life", }; for (const [attribute, value] of Object.entries(status)) { // Cloud consumable percentages if (attribute === "125" || attribute === "126" || attribute === "127") { const val = value >= 0 && value <= 100 ? value : 0; const mappedName = consumableMap[attribute]; const common = handler.getCommonConsumable(mappedName); // Use mapped name await this.adapter.ensureState(`Devices.${duid}.consumables.${mappedName}`, common || {}); await this.adapter.setStateChanged(`Devices.${duid}.consumables.${mappedName}`, { val, ack: true }); } } } }