UNPKG

iobroker.roborock

Version:
965 lines (816 loc) 36.4 kB
import PQueue from "p-queue"; import { BaseDeviceFeatures, DeviceModelConfig, FeatureDependencies } from "../baseDeviceFeatures"; import { Feature } from "../features.enum"; import { StationService } from "./services/StationService"; import { V1ConsumableService } from "./services/V1ConsumableService"; import { V1MapService } from "./services/V1MapService"; import { VACUUM_CONSTANTS } from "./vacuumConstants"; // --- Shared Constants --- export const BASE_FAN = { 101: "Quiet", 102: "Balanced", 103: "Turbo", 104: "Max" }; export const BASE_WATER = { 200: "Off", 201: "Mild", 202: "Moderate", 203: "Intense" }; export const BASE_MOP = { 300: "Standard", 301: "Deep", 303: "Deep+" }; // --- Profile Interface --- export interface VacuumProfile { mappings: { fan_power: Record<number, string>; mop_mode?: Record<number, string>; water_box_mode?: Record<number, string>; error_code?: Record<number, string>; state?: Record<number, string>; }; name?: string; features?: Record<string, any>; cleanMotorModePresets?: Record<string, string>; consumableLifeHours?: Record<string, number>; } export const DEFAULT_PROFILE: VacuumProfile = { mappings: { fan_power: BASE_FAN, mop_mode: { 300: "Standard", 301: "Deep", 303: "Deep+" }, water_box_mode: { 200: "Off", 201: "Mild", 202: "Moderate", 203: "Intense" }, }, }; export class V1VacuumFeatures extends BaseDeviceFeatures { private static readonly autoEmptyDockStartCommand = "app_start_collect_dust"; protected profile: VacuumProfile; protected consumableService: V1ConsumableService; protected stationService: StationService; protected lastMapUpdate = 0; protected detectionComplete = false; protected mapService: V1MapService; constructor(dependencies: FeatureDependencies, duid: string, robotModel: string, config: DeviceModelConfig = { staticFeatures: [] }, profile: VacuumProfile = DEFAULT_PROFILE) { super(dependencies, duid, robotModel, config); // Deep clone profile to avoid mutating shared static objects this.profile = structuredClone(profile); this.consumableService = new V1ConsumableService(this.deps, this.duid, this.profile); this.stationService = new StationService(this.deps, this.duid); this.mapService = new V1MapService(this.deps, this.duid); } public override async initializeDeviceData(): Promise<void> { await this.updateMultiMapsList(); // 1. Load Floor List first (for names/metadata) await this.updateStatus(); // 2. Get Status (triggers Room sync via first floor detection) await this.updateMap(); // 3. Get Map Image // These can still be parallel as they don't depend on each other as much await Promise.all([ this.updateFirmwareFeatures(), this.updateConsumables(), this.updateNetworkInfo(), this.updateTimers(), ]); } /** * Configures the standard command set for Protocol V1 devices. * @see test/unit/features_specification.test.ts for the core vacuum command list. */ public override async setupProtocolFeatures(): Promise<void> { await super.setupProtocolFeatures(); // Add Standard V1 Commands const translations = this.deps.adapter.translations; this.addCommand("app_start", { type: "boolean", role: "button", name: translations["app_start"] || "Start", def: false }); this.addCommand("app_stop", { type: "boolean", role: "button", name: translations["app_stop"] || "Stop", def: false }); this.addCommand("app_pause", { type: "boolean", role: "button", name: translations["app_pause"] || "Pause", def: false }); this.addCommand("app_charge", { type: "boolean", role: "button", name: translations["app_charge"] || "Charge", def: false }); this.addCommand("find_me", { type: "boolean", role: "button", name: translations["find_me"] || "Find Me", def: false }); this.addCommand("app_spot", { type: "boolean", role: "button", name: translations["app_spot"] || "Spot Cleaning", def: false }); this.addCommand("app_segment_clean", { type: "boolean", role: "button", name: "Segment Cleaning", def: false }); // Restore missing standard V1 commands this.addCommand("app_zoned_clean", { type: "json", role: "json", name: "Zone Clean" }); // No default for JSON usually, or "[]" this.addCommand("resume_zoned_clean", { type: "boolean", role: "button", name: "Resume Zone Clean", def: false }); this.addCommand("stop_zoned_clean", { type: "boolean", role: "button", name: "Stop Zone Clean", def: false }); this.addCommand("resume_segment_clean", { type: "boolean", role: "button", name: "Resume Segment Clean", def: false }); this.addCommand("stop_segment_clean", { type: "boolean", role: "button", name: "Stop Segment Clean", def: false }); this.addCommand("app_goto_target", { type: "json", role: "json", name: "Go To Target" }); this.addCommand("load_multi_map", { type: "number", role: "level", name: "Load Map", def: 0 }); this.addCommand("set_custom_mode", { type: "number", role: "level", name: translations["fan_power"] || "Fan Power", states: this.profile.mappings.fan_power, def: Number(Object.keys(this.profile.mappings.fan_power)[0]) }); // Consolidated cleaning mode with all parameters (Custom Mode) // We define states (Presets) to make it selectable in UI this.addCommand("set_clean_motor_mode", { type: "string", role: "value", // changed from json to value to support dropdown name: "Set Custom Cleaning Mode", def: this.profile.cleanMotorModePresets ? Object.keys(this.profile.cleanMotorModePresets)[0] : '{"fan_power":102,"mop_mode":300,"water_box_mode":201}', states: this.profile.cleanMotorModePresets || { '{"fan_power":102,"mop_mode":300,"water_box_mode":201}': "Indv.", '{"fan_power":102,"mop_mode":300,"water_box_mode":200}': "Saugen", '{"fan_power":105,"mop_mode":303,"water_box_mode":202}': "Wischen", '{"fan_power":102,"mop_mode":301,"water_box_mode":201}': "Vac & Mop", '{"fan_power":102,"mop_mode":306,"water_box_mode":201}': "Saugen, dann Wischen", '{"fan_power":106,"mop_mode":302,"water_box_mode":204}': "Smart Plan" } }); if (this.profile.mappings.water_box_mode) { this.addCommand("set_water_box_custom_mode", { type: "number", role: "level", name: translations["water_box_mode"] || "Water Box Mode", states: this.profile.mappings.water_box_mode, def: Number(Object.keys(this.profile.mappings.water_box_mode)[0]) }); } if (this.profile.mappings.mop_mode) { this.addCommand("set_mop_mode", { type: "number", role: "level", name: translations["mop_mode"] || "Mop Mode", states: this.profile.mappings.mop_mode, def: Number(Object.keys(this.profile.mappings.mop_mode)[0]) }); } // A101 Specific: Water Box Distance Off (1-30 -> 230-85) if (this.profile.features?.hasDistanceOff) { this.addCommand("set_water_box_distance_off", { type: "number", role: "level", name: translations["water_box_distance_off"] || "Water Box Distance Off (1-30)", min: 1, max: 30, unit: "", def: 1 }); } this.addCommand("set_clean_repeat_times", { type: "number", role: "value", name: "Clean Repeat Times", min: 1, max: 2, def: 1, states: { 1: "1x", 2: "2x" } }); } public async detectAndApplyRuntimeFeatures(statusData: Readonly<Record<string, any>>): Promise<boolean> { let changed = false; // Detect features based on status keys if (("clean_area" in statusData || "clean_time" in statusData) && await this.applyFeature(Feature.CleaningRecords)) { changed = true; } if (("map_status" in statusData) && await this.applyFeature(Feature.Map)) { changed = true; } if (statusData["water_shortage_status"] !== undefined && await this.applyFeature(Feature.WaterShortage)) { changed = true; } // Consumables detection (usually static, but can check for keys) if (await this.applyFeature(Feature.Consumables)) changed = true; if (statusData["dss"] !== undefined) { const dss = Number(statusData["dss"]); // DockingStationStatus: no applyFeature – folder/states created lazily in updateDockingStationStatus() // Bits 6-7: Dust bag status (0=not supported/missing) if (((dss >> 6) & 0b11) > 0) { await this.applyFeature(Feature.AutoEmptyDock); } // Bits 4-5: Dirty water tank status (0=not supported/missing) // Bits 10-11: Clean water tank status if (((dss >> 4) & 0b11) > 0 || ((dss >> 10) & 0b11) > 0) { await this.applyFeature(Feature.MopWash); } } if (!this.runtimeDetectionComplete) { this.runtimeDetectionComplete = true; changed = true; } return changed; } @BaseDeviceFeatures.DeviceFeature(Feature.Consumables) public async updateConsumables(): Promise<void> { await this.consumableService.updateConsumables(); } @BaseDeviceFeatures.DeviceFeature(Feature.Map) public async updateMap(): Promise<void> { await this.mapService.updateMap(); } public async getCleaningRecordMap(startTime: number) { return this.mapService.getCleaningRecordMap(startTime); } @BaseDeviceFeatures.DeviceFeature(Feature.DockingStationStatus) protected async initDockingStationStatus(): Promise<void> { await this.stationService.initDockingStationStatus(); } /** * Updates docking station status from dss bitfield. Fully dynamic: if the device * sends dss in get_status, we ensure folder/states exist (lazy init) and update. * No per-model or feature-guard – presence of dss means the robot supports it. */ public async updateDockingStationStatus(dss: number): Promise<void> { await this.stationService.initDockingStationStatus(); // idempotent: ensure folder + states await this.stationService.updateDockingStationStatus(dss); } @BaseDeviceFeatures.DeviceFeature(Feature.MultiMap) public async updateMultiMapsList(): Promise<void> { const mapList = await this.mapService.updateMultiMapsList(); if (mapList && Array.isArray(mapList)) { // Update the load_multi_map command states to populate dropdown const states: Record<string, string> = {}; for (const map of mapList) { states[String(map.mapFlag)] = map.name || `Map ${map.mapFlag}`; } await this.deps.adapter.extendObject(`Devices.${this.duid}.commands.load_multi_map`, { common: { type: "number", role: "value", states: states } }); } else { await super.updateMultiMapsList(); } } @BaseDeviceFeatures.DeviceFeature(Feature.RoomMapping) public async updateRoomMapping(): Promise<void> { await this.mapService.updateRoomMapping(); } public override async getCommandParams(method: string, params?: unknown, id?: string): Promise<unknown> { if (method === "reset_consumable" && id) { const obj = await this.deps.adapter.getObjectAsync(id); if (obj && obj.native && obj.native.resetParam) { const resetParam = obj.native.resetParam; this.deps.adapter.rLog("System", this.duid, "Info", "1.0", undefined, `Resetting consumable: ${resetParam} (via native param)`, "info"); return [resetParam]; } // Fallback if no native param (should not happen with new setup) this.deps.adapter.rLog("System", this.duid, "Warn", "1.0", undefined, `Reset consumable called without native param for ${id}`, "warn"); } if (method === "set_clean_motor_mode") { // Log shows "set_clean_motor_mode" works, but expects params as array: [{...}] let finalParams = params; // If input is a string (e.g. from Dropdown/Presets), parse it first if (typeof finalParams === "string") { try { finalParams = JSON.parse(finalParams); } catch (e: any) { this.deps.adapter.rLog("Requests", this.duid, "Warn", this.protocolVersion || undefined, undefined, `[getCommandParams] Failed to parse set_clean_motor_mode params: ${finalParams} - Error: ${e.message}`, "warn"); } } if (finalParams && !Array.isArray(finalParams)) { finalParams = [finalParams]; } return { method: "set_clean_motor_mode", params: finalParams }; } if (method === "load_multi_map") { // Reset current map index -> Next status update triggers room refresh this.mapService.resetCurrentMapIndex(); // User request: active fetch status after map load to ensure trigger // We trigger these in the background immediately (async () => { await new Promise(r => setTimeout(r, 2000)); await this.updateStatus().catch(() => {}); await this.mapService.updateMap().catch(() => {}); await this.mapService.updateRoomMapping().catch(() => {}); })(); // V1 protocol (0.6.19) expects [number] for load_multi_map return [params]; } if (method === "app_segment_clean") { const repeat = await this.getCleanRepeatTimes(); // If params are explicitly provided (e.g. from single room button), use them. if (params && (Array.isArray(params) || typeof params === "object")) { // If it's just a room ID or array of IDs, wrap it in the correct payload structure if (Array.isArray(params) && typeof params[0] === "number") { const roomIds = params as number[]; this.deps.adapter.rLog("System", this.duid, "Info", "1.0", undefined, `Starting segment cleaning for specific rooms: ${roomIds.join(", ")} with repeat ${repeat}`, "info"); return [{ segments: roomIds, repeat, clean_order_mode: 0, clean_mop: 0 }]; } return params; } // Gather selected rooms from floors const namespace = this.deps.adapter.namespace; // Pattern to find states under floors. Structure: Devices.<duid>.floors.<floorID>.<roomID> const pattern = `${namespace}.Devices.${this.duid}.floors.*.*`; const states = await this.deps.adapter.getStatesAsync(pattern); const roomIds: number[] = []; if (states) { for (const [id, state] of Object.entries(states)) { if (state && (state.val === true || state.val === "true" || state.val === 1)) { // Extract Room ID directly from the state path (last segment) const parts = id.split("."); const rid = Number(parts[parts.length - 1]); if (!isNaN(rid)) { roomIds.push(rid); } } } } if (roomIds.length > 0) { this.deps.adapter.rLog("System", this.duid, "Info", "1.0", undefined, `Starting segment cleaning for rooms: ${roomIds.join(", ")} with repeat ${repeat}`, "info"); // Params: // params: [{"clean_mop":0,"clean_order_mode":0,"repeat":2,"segments":[2,1]}] const payload = [{ segments: roomIds, repeat, clean_order_mode: 0, clean_mop: 0 }]; return payload; } else { this.deps.adapter.rLog("System", this.duid, "Warn", "1.0", undefined, `No rooms selected for segment cleaning!`, "warn"); return []; } } if (method === "set_custom_mode") { return [Number(params)]; } if (method === "set_mop_mode") { return [Number(params)]; } if (method === "set_water_box_custom_mode") { return [Number(params)]; } if (method === "set_water_box_distance_off") { // Convert 1-30 slider to 230-85 robot value // Formula: 230 - ((val - 1) * 5) let val = Number(params); if (isNaN(val)) val = 1; if (val < 1) val = 1; if (val > 30) val = 30; const distance_off = 230 - ((val - 1) * 5); return { distance_off }; } if (method === "set_clean_repeat_times") { let repeat = Number(params); if (isNaN(repeat)) repeat = 1; return { repeat }; } if (method === V1VacuumFeatures.autoEmptyDockStartCommand) { return []; } return params; } private normalizeCleanRepeat(value: unknown): number | null { const repeat = Number(value); return Number.isInteger(repeat) && repeat > 0 ? repeat : null; } private async getCleanRepeatTimes(): Promise<number> { const commandState = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.commands.set_clean_repeat_times`); const commandRepeat = this.normalizeCleanRepeat(commandState?.val); if (commandRepeat !== null) return commandRepeat; const statusState = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.deviceStatus.repeat`); return this.normalizeCleanRepeat(statusState?.val) ?? 1; } public async updateCleanSummary(): Promise<void> { try { const result = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_clean_summary", []); const summary = this.normalizeV1CleanSummary(result); if (!summary) { this.deps.adapter.rLog("System", this.duid, "Warn", "1.0", undefined, "Invalid V1 clean summary format", "warn"); return; } await this.processV1CleanSummary(summary); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update cleaningInfo (method: get_clean_summary): ${this.deps.adapter.errorMessage(e)}`, "warn"); } } private normalizeV1CleanSummary(result: unknown): Record<string, unknown> | null { const unwrapped = this.unwrapSingleElementArrays(result); if (Array.isArray(unwrapped)) { const numericEntries = this.getIndexedNumbers(unwrapped); if (numericEntries.length === 0) return null; const records = this.findRecordStartTimes(unwrapped); const summary = this.inferV1CleanSummaryFields(numericEntries, records.length); for (const { index, value } of numericEntries) { summary[`field_${index}`] = value; } summary.records = records; return summary; } if (this.isPlainObject(unwrapped)) { const summary = { ...unwrapped }; if (Array.isArray(summary.records)) { summary.records = this.normalizeRecordStartTimes(summary.records); } return summary; } return null; } private inferV1CleanSummaryFields(entries: Array<{ index: number; value: number }>, recordCount: number): Record<string, unknown> { const summary: Record<string, unknown> = {}; const used = new Set<number>(); const area = entries .filter(entry => entry.value >= 1_000_000) .sort((a, b) => b.value - a.value)[0]; if (area) { summary.clean_area = area.value; used.add(area.index); } const count = entries .filter(entry => !used.has(entry.index) && entry.value >= recordCount && entry.value < 1_000_000) .sort((a, b) => a.value - b.value)[0]; if (count) { summary.clean_count = count.value; used.add(count.index); } const time = entries .filter(entry => !used.has(entry.index)) .sort((a, b) => b.value - a.value)[0]; if (time) { summary.clean_time = time.value; } return summary; } private async processV1CleanSummary(summary: Record<string, unknown>): Promise<void> { const records = Array.isArray(summary.records) ? this.normalizeRecordStartTimes(summary.records).sort((a, b) => b - a) : []; const recordPayloads = await this.fetchV1CleanRecordPayloads(records); const rest = { ...summary }; delete rest.records; await this.deps.ensureFolder(`Devices.${this.duid}.cleaningInfo`); for (const key in rest) { await this.processResultKey("cleaningInfo", key, rest[key]); } await this.writeV1CleaningInfoJson(records, recordPayloads); await this.syncV1CleanRecords(records, recordPayloads); } private async fetchV1CleanRecordPayloads(records: number[]): Promise<Map<number, unknown>> { const payloads = new Map<number, unknown>(); for (const startTime of records) { try { const rawRecord = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_clean_record", [startTime]); const payload = this.unwrapSingleElementArrays(rawRecord); if (!(Array.isArray(payload) && payload.length === 0)) { payloads.set(startTime, payload); } } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Warn", "1.0", undefined, `Failed to fetch clean record ${startTime} for cleaningInfo.JSON: ${this.deps.adapter.errorMessage(e)}`, "warn"); } } return payloads; } private async writeV1CleaningInfoJson(records: number[], recordPayloads: Map<number, unknown>): Promise<void> { const jsonRecords = records.map(startTime => recordPayloads.get(startTime) ?? null); await this.deps.ensureState(`Devices.${this.duid}.cleaningInfo.JSON`, { name: "cleaningInfoJSON", type: "string", role: "json", read: true, write: false }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.cleaningInfo.JSON`, { val: JSON.stringify(jsonRecords), ack: true }); } private async syncV1CleanRecords(records: number[], recordPayloads: Map<number, unknown> = new Map()): Promise<void> { if (records.length === 0) return; const mapQueue = new PQueue({ concurrency: 1 }); const existingStartTimes: Record<string, number> = {}; const namespace = this.deps.adapter.namespace; const recordIds = records.map((_, i) => `${namespace}.Devices.${this.duid}.cleaningInfo.records.${i}.startTime`); const states = await this.deps.adapter.getForeignStatesAsync(recordIds); if (states) { for (const id in states) { if (states[id] && states[id].val) { const parts = id.split("."); const index = parseInt(parts[parts.length - 2]); if (!isNaN(index)) { existingStartTimes[String(states[id].val)] = index; } } } } const sortedRecords = [...records].sort((a, b) => b - a); const moves: { old: number, new: number }[] = []; const newRecs: { index: number, time: number }[] = []; for (let i = 0; i < sortedRecords.length; i++) { const time = sortedRecords[i]; const oldIndex = existingStartTimes[time]; if (oldIndex !== undefined && oldIndex !== i) { moves.push({ old: oldIndex, new: i }); } else if (oldIndex === undefined) { newRecs.push({ index: i, time }); } } const leftShifts = moves.filter(m => m.old > m.new).sort((a, b) => a.new - b.new); for (const m of leftShifts) { await this.copyRecordStates(m.old, m.new); } const rightShifts = moves.filter(m => m.old < m.new).sort((a, b) => b.new - a.new); for (const m of rightShifts) { await this.copyRecordStates(m.old, m.new); } for (const { index, time } of newRecs) { await this.fetchAndSaveRecord(time, index, index < 3 ? 10 : 0, mapQueue, recordPayloads.get(time)); } await mapQueue.onIdle(); } private async copyRecordStates(from: number, to: number): Promise<void> { const prefix = `Devices.${this.duid}.cleaningInfo.records`; const states = await this.deps.adapter.getStatesAsync(`${prefix}.${from}.*`); if (!states) return; await Promise.all(Object.entries(states).map(async ([id, state]) => { if (!state || state.val === null) return; const obj = await this.deps.adapter.getObjectAsync(id); if (!obj?.common) return; // Replace index in path (roborock.0.Devices...records.5... -> ...records.6...) const destRel = id.substring(this.deps.adapter.namespace.length + 1).replace(`.records.${from}.`, `.records.${to}.`); await this.deps.ensureState(destRel, obj.common as Partial<ioBroker.StateCommon>); await this.deps.adapter.setStateChanged(destRel, { val: state.val, ack: true }); })); } private async fetchAndSaveRecord(startTime: number, index: number, priority: number, queue: PQueue, prefetchedRecord?: unknown): Promise<void> { queue.add(async () => { try { const fullRecordPath = `cleaningInfo.records.${index}`; // 1. Set Timestamp await this.deps.ensureState(`Devices.${this.duid}.${fullRecordPath}.startTime`, { name: "Start Time", type: "number", role: "value.time", write: false }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.${fullRecordPath}.startTime`, { val: startTime, ack: true }); // 2. Fetch Metadata const recordsDetails = prefetchedRecord !== undefined ? prefetchedRecord : await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_clean_record", [startTime]); const record = this.normalizeV1CleanRecord(recordsDetails); if (record) { for (const key in record) { let val = record[key]; if (key === "area" || key === "cleaned_area") val = Math.round(Number(val) / 1000000); else if (key === "duration") val = Math.round(Number(val) / 60); await this.processResultKey(fullRecordPath, key, val); } } // 3. Fetch Map only when map creation is enabled (records metadata is always saved above) if (this.deps.config.enable_map_creation) { const mapResult = await this.mapService.getCleaningRecordMap(startTime); if (mapResult) { const mapFolder = `records.${index}.map`; await this.deps.ensureFolder(`Devices.${this.duid}.cleaningInfo.${mapFolder}`); const saveMap = async (suffix: string, name: string, val: string, role = "text.png") => { await this.deps.ensureState(`Devices.${this.duid}.cleaningInfo.${mapFolder}.${suffix}`, { name, type: "string", role }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.cleaningInfo.${mapFolder}.${suffix}`, { val, ack: true }); }; await saveMap("mapBase64", "Map Image", mapResult.mapBase64); await saveMap("mapData", "Map Data", mapResult.mapData, "json"); } else { this.deps.adapter.rLog("MapManager", this.duid, "Warn", "1.0", undefined, `No map found for record ${startTime}`, "warn"); } } } catch (e: any) { this.deps.adapter.rLog("System", this.duid, "Warn", "1.0", undefined, `Background fetch for record ${startTime} failed: ${e.message}`, "warn"); } }, { priority }); } private normalizeV1CleanRecord(result: unknown): Record<string, unknown> | null { const unwrapped = this.unwrapSingleElementArrays(result); if (Array.isArray(unwrapped)) { const record = this.inferV1CleanRecordFields(unwrapped); unwrapped.forEach((value, index) => { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { record[`field_${index}`] = value; } }); return Object.keys(record).length > 0 ? record : null; } if (this.isPlainObject(unwrapped)) { return { ...unwrapped }; } return null; } private inferV1CleanRecordFields(values: unknown[]): Record<string, unknown> { const record: Record<string, unknown> = {}; const timestampPairIndex = this.findAdjacentTimestampPairIndex(values); if (timestampPairIndex === -1) return record; record.begin = values[timestampPairIndex]; record.end = values[timestampPairIndex + 1]; const duration = values[timestampPairIndex + 2]; if (typeof duration === "number" && Number.isFinite(duration) && duration >= 0) { record.duration = duration; } const area = values[timestampPairIndex + 3]; if (typeof area === "number" && Number.isFinite(area) && area >= 0) { record.area = area; } return record; } private findAdjacentTimestampPairIndex(values: unknown[]): number { for (let i = 0; i < values.length - 1; i++) { const begin = values[i]; const end = values[i + 1]; if (this.isUnixTimestamp(begin) && this.isUnixTimestamp(end) && begin <= end) { return i; } } return -1; } private isUnixTimestamp(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 946684800 && value < 4102444800; } private getIndexedNumbers(values: unknown[]): Array<{ index: number; value: number }> { const result: Array<{ index: number; value: number }> = []; values.forEach((value, index) => { if (typeof value === "number" && Number.isFinite(value)) { result.push({ index, value }); } }); return result; } private findRecordStartTimes(values: unknown[]): number[] { for (const value of values) { if (Array.isArray(value)) { const records = this.normalizeRecordStartTimes(value); if (records.length > 0) return records; } } return []; } private normalizeRecordStartTimes(records: unknown): number[] { if (!Array.isArray(records)) return []; return records.map(record => Number(record)).filter(Number.isFinite); } private unwrapSingleElementArrays(value: unknown): unknown { let unwrapped = value; while (Array.isArray(unwrapped) && unwrapped.length === 1) { unwrapped = unwrapped[0]; } return unwrapped; } private isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) && !Buffer.isBuffer(value); } public override async updateStatus(): Promise<void> { try { const result = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_prop", ["get_status"]); const statusData = Array.isArray(result) ? result[0] : result; if (statusData && typeof statusData === "object") { if (!this.runtimeDetectionComplete) { await this.detectAndApplyRuntimeFeatures(statusData); } await this.processStatus(statusData); const c = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.cleaningInfo.clean_count`); this.deps.adapter.rLog("System", this.duid, "Debug", "1.0", undefined, `status=${statusData.state ?? "?"}, clean_count=${c?.val ?? "?"}`, "debug"); } } catch (e: any) { this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update status: ${e.message}`, "warn"); throw e; } } public async updateTimers(): Promise<void> { try { const timers = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "get_timer", []); if (Array.isArray(timers)) { await this.deps.ensureFolder(`Devices.${this.duid}.schedules`); await Promise.all(timers.map(async (timer) => { // timer structure: [id, enabled, [cron, [cmd, params], createTime]] if (Array.isArray(timer) && timer.length >= 3) { const id = timer[0]; const enabled = timer[1] === "on"; const segments = timer[2]; const cron = Array.isArray(segments) ? segments[0] : ""; await this.deps.ensureFolder(`Devices.${this.duid}.schedules.${id}`); await this.deps.ensureState(`Devices.${this.duid}.schedules.${id}.enabled`, { name: "Enabled", type: "boolean", role: "switch", write: true }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.schedules.${id}.enabled`, { val: enabled, ack: true }); await this.deps.ensureState(`Devices.${this.duid}.schedules.${id}.cron`, { name: "CRON", type: "string", role: "text", write: false }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.schedules.${id}.cron`, { val: cron, ack: true }); } })); } } catch (e: any) { this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update timers: ${e.message}`, "warn"); } } public async processStatus(status: any): Promise<void> { const validStatus = status || {}; if (validStatus.dss !== undefined) { await this.updateDockingStationStatus(Number(validStatus.dss)); delete validStatus.dss; } // Define property processing map const processors: Record<string, (val: any) => Promise<void>> = { state: async (val) => { await this.deps.ensureState(`Devices.${this.duid}.deviceStatus.state`, { type: "number", states: this.profile.mappings.state || VACUUM_CONSTANTS.stateCodes }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.deviceStatus.state`, { val, ack: true }); }, error_code: async (val) => { await this.deps.ensureState(`Devices.${this.duid}.deviceStatus.error_code`, { type: "number", states: this.profile.mappings.error_code || VACUUM_CONSTANTS.errorCodes }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.deviceStatus.error_code`, { val, ack: true }); }, fan_power: async (val) => { await this.deps.ensureState(`Devices.${this.duid}.deviceStatus.fan_power`, { type: "number", states: this.profile.mappings.fan_power }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.deviceStatus.fan_power`, { val, ack: true }); // Sync to command state await this.deps.adapter.setStateChanged(`Devices.${this.duid}.commands.set_custom_mode`, { val, ack: true }); }, mop_mode: async (val) => { if (this.profile.mappings.mop_mode) { await this.deps.ensureState(`Devices.${this.duid}.deviceStatus.mop_mode`, { type: "number", states: this.profile.mappings.mop_mode }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.deviceStatus.mop_mode`, { val, ack: true }); // Sync to command state await this.deps.adapter.setStateChanged(`Devices.${this.duid}.commands.set_mop_mode`, { val, ack: true }); } }, water_box_mode: async (val) => { if (this.profile.mappings.water_box_mode) { await this.deps.ensureState(`Devices.${this.duid}.deviceStatus.water_box_mode`, { type: "number", states: this.profile.mappings.water_box_mode }); await this.deps.adapter.setStateChanged(`Devices.${this.duid}.deviceStatus.water_box_mode`, { val, ack: true }); // Sync to command state await this.deps.adapter.setStateChanged(`Devices.${this.duid}.commands.set_water_box_custom_mode`, { val, ack: true }); } } }; // Parallel processing of remaining status properties const promises: Promise<void>[] = []; for (const key in validStatus) { if (processors[key]) { promises.push(processors[key](validStatus[key])); } else { // Default handler for generic properties promises.push(this.processResultKey("deviceStatus", key, validStatus[key])); } } await Promise.all(promises); } protected getDynamicFeatures(): Set<Feature> { // v1 dynamic features const features = new Set<Feature>(); if (this.config.staticFeatures) { this.config.staticFeatures.forEach(f => features.add(f)); } return features; } // --- Abstract Method Implementations --- public getCommonConsumable(attribute: string | number): Partial<ioBroker.StateCommon> | undefined { return (VACUUM_CONSTANTS.consumables as any)[attribute]; } public isResetableConsumable(consumable: string): boolean { return VACUUM_CONSTANTS.resetConsumables.has(consumable); } public getCommonDeviceStates(attribute: string | number): Partial<ioBroker.StateCommon> | undefined { return (VACUUM_CONSTANTS.deviceStates as any)[attribute]; } public getCommonCleaningRecords(attribute: string | number): Partial<ioBroker.StateCommon> | undefined { return (VACUUM_CONSTANTS.cleaningRecords as any)[attribute]; } public getFirmwareFeatureName(featureID: string | number): string { return (VACUUM_CONSTANTS.firmwareFeatures as any)[featureID] || `Feature ${featureID}`; } public getCommonCleaningInfo(attribute: string | number): Partial<ioBroker.StateCommon> | undefined { return (VACUUM_CONSTANTS.cleaningInfo as any)[attribute]; } @BaseDeviceFeatures.DeviceFeature(Feature.AutoEmptyDock) public async initAutoEmptyDock(): Promise<void> { this.addCommand(V1VacuumFeatures.autoEmptyDockStartCommand, { type: "boolean", role: "button", name: "Start Collect Dust", def: false }); } @BaseDeviceFeatures.DeviceFeature(Feature.MopWash) public async initMopWash(): Promise<void> { this.addCommand("app_start_wash", { type: "boolean", role: "button", name: "Start Mop Wash", def: false }); this.addCommand("app_stop_wash", { type: "boolean", role: "button", name: "Stop Mop Wash", def: false }); } @BaseDeviceFeatures.DeviceFeature(Feature.MopDry) public async initMopDry(): Promise<void> { this.addCommand("app_start_mop_drying", { type: "boolean", role: "button", name: "Start Mop Drying", def: false }); this.addCommand("app_stop_mop_drying", { type: "boolean", role: "button", name: "Stop Mop Drying", def: false }); } protected override async processResultKey(folder: string, key: string, val: unknown): Promise<void> { if (key === "map_status") { const mapIdxChanged = this.mapService.updateCurrentMapIndex(Number(val)); if (mapIdxChanged) { this.deps.adapter.rLog("MapManager", this.duid, "Info", "1.0", undefined, `[MapSync] Map changed to index ${this.mapService.currentIndex}. Updating room mapping.`, "info"); await this.updateRoomMapping(); } } else if (key === "clean_time") { // cleaningInfo (Total) = Hours, deviceStatus (Current) = Minutes const divisor = folder.includes("cleaningInfo") ? 3600 : 60; val = Math.round(Number(val) / divisor); } else if (key === "clean_area") { val = Math.round(Number(val) / 1000000); // mm² -> m² } await super.processResultKey(folder, key, val); } public override getCurrentMapIndex(): number { return this.mapService.currentIndex; } }