UNPKG

iobroker.roborock

Version:
785 lines (677 loc) 28.5 kB
// src/lib/features/base_device_features.ts import { z } from "zod"; import type { Roborock } from "../../main"; import { DeviceStateWriter } from "./deviceStateWriter"; import { Feature } from "./features.enum"; // --- Types & Interfaces --- /** * Command object properties. */ export type CommandSpec = { type: ioBroker.CommonType | "json"; // 'json' type used for internal logic def?: any; states?: Record<string | number, string>; min?: number; max?: number; unit?: string; role?: string; }; /** * Feature implementation function, 'this' context is bound. */ export type FeatureImplementation = () => Promise<void> | void; /** * Model-specific configuration. */ export interface DeviceModelConfig { staticFeatures: Feature[]; // Features this model always has } /** * Feature class constructor signature. */ export type FeatureClassConstructor = new (_dependencies: FeatureDependencies, _duid: string) => BaseDeviceFeatures; /** * Dependencies injected into feature classes. */ export interface FeatureDependencies { adapter: Roborock; config: Roborock["config"]; http_api: Roborock["http_api"]; ensureState: Roborock["ensureState"]; ensureFolder: Roborock["ensureFolder"]; log: Roborock["log"]; // Add other dependencies if needed } // --- Registry & Decorator --- /** Maps robotModelId to feature class constructors. */ const modelRegistry = new Map<string, FeatureClassConstructor>(); /** * Decorator to register a feature class for a robot model. * @param robotModelId Unique model identifier (e.g. 'roborock.vacuum.a70'). */ export function RegisterModel(robotModelId: string) { return function (constructor: FeatureClassConstructor) { if (modelRegistry.has(robotModelId)) { // Model already registered, overwriting. } modelRegistry.set(robotModelId, constructor); }; } // --- Zod Schemas (Base) --- /** * Base Zod schema for generic status properties. */ export const BaseStatusSchema = z.looseObject({ error_code: z.number().int().optional(), // Add generic status fields if applicable }); // --- Generic Base Class --- /** * Base class for device features. Handles init, feature application, and commands. * Extended by specific types (e.g. V1VacuumFeatures). */ export abstract class BaseDeviceFeatures { protected createdStates: Set<string> = new Set(); // Track created states to avoid redundant ensureState calls protected runtimeDetectionComplete = false; // Initial runtime detection flag protected readonly stateWriter: DeviceStateWriter; protected deps: FeatureDependencies; public commands: Record<string, CommandSpec | any>; // Command definitions for this device public extraCommandGroups: Record<string, Record<string, CommandSpec | any>>; protected duid: string; protected robotModel: string; public protocolVersion: string | null = null; protected config: DeviceModelConfig; // Static feature config from model class protected appliedFeatures = new Set<Feature>(); // Tracks applied features protected pendingFeatures = new Set<Feature>(); // Tracks features currently being applied (Race Condition Guard) protected commandsCreated = false; // Command objects created flag // --- Constants (Generic) --- protected static readonly CONSTANTS = { // Generic constants for all Roborock devices baseCommands: {}, // Generic error codes (subset) errorCodes: { 0: "No error", 255: "Internal error", "-1": "Unknown Error", // Add more if generic across all devices }, }; // --- Metadata Key for Feature Registry --- // Unique symbol for registry on prototype public static readonly FEATURE_METADATA_KEY = Symbol.for("roborock.featureRegistry"); /** * Decorator to register a feature handler method. * @param feature The Feature enum key. */ public static DeviceFeature(feature: Feature) { return function (target: any, propertyKey: string) { // 'target' is the prototype let registry: Map<Feature, string> = target[BaseDeviceFeatures.FEATURE_METADATA_KEY]; if (!registry) { registry = new Map(); // Store on prototype target[BaseDeviceFeatures.FEATURE_METADATA_KEY] = registry; } registry.set(feature, propertyKey); }; } // --- Feature Registry (Instance Based via Metadata) --- /** * Base feature handler constructor. * @param dependencies Injected dependencies. * @param duid Device unique identifier. * @param robotModel Robot model string. * @param config Static feature config. */ constructor(dependencies: FeatureDependencies, duid: string, robotModel: string, config: DeviceModelConfig) { this.deps = dependencies; this.duid = duid; this.robotModel = robotModel; this.config = config; this.stateWriter = new DeviceStateWriter(dependencies, duid); // Initialize empty commands map. Actual commands will be populated during setupProtocolFeatures. this.commands = {}; this.extraCommandGroups = {}; } /** * Applies a feature if not already applied. Looks up implementation in registry. * @param feature Feature enum key. * @returns `true` if applied now. */ protected async applyFeature(feature: Feature): Promise<boolean> { // Validate input feature if (!feature || !Object.values(Feature).includes(feature)) { this.deps.log.warn(`[${this.duid}] Attempted to apply invalid feature value: ${feature}`); return false; } // Check if already applied or pending if (this.appliedFeatures.has(feature) || this.pendingFeatures.has(feature)) { return false; } // Get registry from instance metadata (prototype chain) const registry: Map<Feature, string> | undefined = (this as any)[BaseDeviceFeatures.FEATURE_METADATA_KEY]; if (registry && registry.has(feature)) { const methodName = registry.get(feature)!; this.pendingFeatures.add(feature); // Lock try { const applyMethod = (this as unknown as Record<string, () => Promise<void>>)[methodName]; if (typeof applyMethod !== "function") throw new Error(`Feature ${String(feature)}: missing method ${methodName}`); await applyMethod.call(this); this.appliedFeatures.add(feature); // Mark applied after success return true; } catch (e: unknown) { const stack = e instanceof Error ? e.stack : ""; this.deps.log.error(`[FeatureApply|${this.robotModel}|${this.duid}] Error applying feature '${feature}': ${this.deps.adapter.errorMessage(e)} ${stack}`); return false; } finally { this.pendingFeatures.delete(feature); // Unlock } } else { return false; } } // --- Abstract / Overridable Methods --- /** * Detects features via device-specific mechanisms (bitfields, fw info). * Implemented by subclasses. * @returns Set of detected `Feature` enum keys. */ protected abstract getDynamicFeatures(): Set<Feature>; /** * Applies static features from config. * Override for pre-runtime model logic. * @param _statusData Optional initial status data. * @param _fwFeatures Optional initial firmware features. */ public async applyModelSpecifics(): Promise<void> { const promises = this.config.staticFeatures.map((feature) => this.applyFeature(feature)); await Promise.all(promises); } /** * Performs runtime feature detection using status data. * Implemented by subclasses. * @param statusData Validated status data. * @param fwFeatures Optional firmware features. * @returns `true` if features/commands changed. */ public abstract detectAndApplyRuntimeFeatures(_statusData: Readonly<Record<string, any>>): Promise<boolean>; // --- Core Initialization Logic --- /** * Initializes features: Model Specifics -> Runtime Detection -> Dock Processing -> Command Objects. * @param initialStatus Optional initial status. * @param initialFwFeatures Optional initial firmware features. */ public async initialize(online: boolean = false): Promise<void> { // Flow: Protocol -> Model Specifics -> Runtime Detection -> Dock Processing -> Command Objects // 0. Setup Protocol Features (Command Sets) try { await this.setupProtocolFeatures(); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Error setting up protocol features: ${this.deps.adapter.errorMessage(e)}`, "error"); } // 1. Apply Model Specifics try { await this.applyModelSpecifics(); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Error applying model specifics: ${this.deps.adapter.errorMessage(e)}`, "error"); } // 2. Create/Update ioBroker Objects (Commands) // Must be done BEFORE fetching data, as data updates might sync to command states. try { await this.createCommandObjects(); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Error creating command objects: ${this.deps.adapter.errorMessage(e)}`, "error"); } // 3. Fetch initial data if online if (online) { try { await this.initializeDeviceData(); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Error initializing device data: ${this.deps.adapter.errorMessage(e)}`, "error"); } } } /** * Fetches initial runtime data (status, consumables, map). */ public async initializeDeviceData(): Promise<void> { // Default implementation: update status if online await this.updateStatus(); await this.updateFirmwareFeatures(); await this.updateMap(); } public async setupProtocolFeatures(): Promise<void> { // Initialize with generic base commands this.commands = JSON.parse(JSON.stringify(BaseDeviceFeatures.CONSTANTS.baseCommands)); this.extraCommandGroups = {}; } /** * Logs summary of applied features and commands. Call after init. */ public printSummary(): void { } // --- Core Helper Methods --- /** * Maps dynamic feature keys (e.g. 'is...') to action keys (e.g. 'MopWash'). * @param detectedFeature Detected Feature enum key. * @returns Mapped action Feature key, detected key if actionable, or null. */ protected mapFeature(detectedFeature: Feature): Feature | null { // Get registry from instance metadata const registry: Map<Feature, string> | undefined = (this as any)[BaseDeviceFeatures.FEATURE_METADATA_KEY]; // Check if 'is...' key value exists as enum key const potentialActionName = Feature[detectedFeature as keyof typeof Feature]; // Find enum key for string value, excluding original key const mappedActionKey = (Object.keys(Feature) as Array<keyof typeof Feature>).find((key) => Feature[key] === potentialActionName && key !== detectedFeature); if (mappedActionKey) { const actionFeatureEnum = Feature[mappedActionKey]; // Check if mapped action has registered implementation if (registry && registry.has(actionFeatureEnum)) { this.deps.adapter.rLog("System", this.duid, "Debug", undefined, undefined, `Mapping dynamic feature '${detectedFeature}' to action '${actionFeatureEnum}'`, "debug"); return actionFeatureEnum; } else { this.deps.adapter.rLog("System", this.duid, "Debug", undefined, undefined, `Dynamic feature '${detectedFeature}' mapped to '${actionFeatureEnum}', but no action registered.`, "debug"); return null; } } // Check if detected feature has registered action if (registry && registry.has(detectedFeature)) { this.deps.adapter.rLog("System", this.duid, "Debug", undefined, undefined, `Using dynamic feature '${detectedFeature}' directly.`, "debug"); return detectedFeature; } // No mapping or action found this.deps.adapter.rLog("System", this.duid, "Debug", undefined, undefined, `Dynamic feature '${detectedFeature}' detected but has no registered action or mapping.`, "debug"); return null; } /** * Creates/updates ioBroker command objects from this.commands. */ public async createCommandObjects(): Promise<void> { const commandGroups: Record<string, Record<string, CommandSpec | any>> = { commands: this.commands, ...this.extraCommandGroups }; const promises: Promise<void>[] = []; for (const [folderName, groupCommands] of Object.entries(commandGroups)) { const folderPath = `Devices.${this.duid}.${folderName}`; try { await this.deps.ensureFolder(folderPath); } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Failed to ensure commands folder ${folderPath}: ${this.deps.adapter.errorMessage(e)}`, "error"); return; } for (const [command, commonCommand] of Object.entries(groupCommands)) { promises.push(this.processCommand(folderPath, command, commonCommand)); } } try { await Promise.all(promises); // Wait for all operations this.commandsCreated = true; // Done } catch (e: unknown) { // Catch Promise.all errors (rare) this.deps.log.error(`[${this.duid}] Critical error during parallel command object creation: ${this.deps.adapter.errorMessage(e)}`); } } /** * Process a single command object creation. */ protected async processCommand(folderPath: string, cmd: string, spec: CommandSpec | any): Promise<void> { try { const options: Partial<ioBroker.StateCommon> = { ...(spec as Partial<ioBroker.StateCommon>), name: spec.name || this.deps.adapter.translations[cmd] || cmd, // Add name generation write: true, // Writable }; const originalType = spec.type; // Store original type // Determine Role if (!options.role) { if (originalType === "boolean" && !options.states) options.role = "button"; else if (originalType === "number" && options.states) options.role = "value.list"; else if (originalType === "number") options.role = "level"; else if (originalType === "json" && options.states) options.role = "value.list"; else if (originalType === "json") options.role = "json"; else options.role = "state"; } // Enforce default value if missing (User requirement: no null defaults) if (options.def === undefined || options.def === null) { if (options.type === "boolean" || options.role === "button") { options.def = false; } else if (options.type === "number") { options.def = options.min ?? 0; } else if (options.type === "string") { options.def = ""; } } // Adjust type if (originalType === "json") { options.type = "string"; } // Type validation and default const validTypes: ioBroker.CommonType[] = ["string", "number", "boolean", "object", "array", "mixed"]; if (!options.type || typeof options.type !== "string" || !validTypes.includes(options.type as ioBroker.CommonType)) { if (originalType !== "json") { // Skip log if setting to string this.deps.log.warn(`[${this.duid}] Invalid or missing type '${spec.type}' for command '${cmd}', defaulting to 'string'.`); } options.type = "string"; } const path = `${folderPath}.${cmd}`; // Create/Update Object const existingObj = await this.deps.adapter.getObjectAsync(path); if (existingObj) { // Extend if common differs. Stringify is good enough for now. if (JSON.stringify(existingObj.common) !== JSON.stringify(options)) { await this.deps.adapter.extendObject(path, { common: options as ioBroker.StateCommon }); } } else { await this.deps.ensureState(path, options as ioBroker.StateCommon); } // Reset button states if (options.role === "button" && options.type === "boolean") { const currentState = await this.deps.adapter.getStateAsync(path); // Reset to false if needed if (!currentState || currentState.val !== false) { await this.deps.adapter.setState(path, false, true); } } } catch (e: unknown) { this.deps.log.error(`[${this.duid}] Error processing command object '${cmd}': ${this.deps.adapter.errorMessage(e)}`); } } // --- Helper Methods --- /** * Adds/updates command definition. Merges states to preserve specifics. * @param name Command name. * @param spec CommandSpec definition. */ protected addCommand(name: string, spec: CommandSpec | any, group = "commands"): void { if (!name || typeof name !== "string") { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `addCommand: Invalid command name provided: ${name}`, "error"); return; } try { let targetGroup = this.commands; if (group !== "commands") { if (!this.extraCommandGroups[group]) { this.extraCommandGroups[group] = {}; } targetGroup = this.extraCommandGroups[group]; } // Merge states if new spec has fewer states. if (targetGroup[name]?.states && spec.states) { const existingStatesJson = JSON.stringify(targetGroup[name].states); const newStatesJson = JSON.stringify(spec.states); if (existingStatesJson !== newStatesJson) { // Merge: New states overwrite/add spec.states = { ...targetGroup[name].states, ...spec.states }; } else { // Preserve existing spec if states identical spec = { ...targetGroup[name], ...spec, states: targetGroup[name].states }; } } else if (targetGroup[name]?.states && !spec.states) { // Keep existing states if new one has none spec.states = targetGroup[name].states; } targetGroup[name] = spec; } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Error in addCommand for '${name}': ${this.deps.adapter.errorMessage(e)}`, "error"); } } public getCommandFolders(): string[] { return ["commands", ...Object.keys(this.extraCommandGroups)]; } public hasCommandFolder(folder: string): boolean { return folder === "commands" || Object.prototype.hasOwnProperty.call(this.extraCommandGroups, folder); } public getCommandSpec(folder: string, command: string): CommandSpec | any | undefined { if (folder === "commands") { return this.commands[command]; } return this.extraCommandGroups[folder]?.[command]; } /** * Calls injected ensureState with correct path. * @param subfolder Subfolder name. * @param stateName State name. * @param commonOptions State options. * @param native Optional native options. */ protected async ensureState(subfolder: string, stateName: string, commonOptions: Partial<ioBroker.StateCommon>, native: Record<string, any> = {}): Promise<void> { const path = `Devices.${this.duid}.${subfolder}.${stateName}`; try { // Validate type before ensureState const validTypes: ioBroker.CommonType[] = ["string", "number", "boolean", "object", "array", "mixed"]; if (commonOptions.type && !validTypes.includes(commonOptions.type as ioBroker.CommonType)) { this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Invalid type '${commonOptions.type}' in ensureState for ${path}, defaulting to 'string'.`, "warn"); commonOptions.type = "string"; } // Check if object exists and needs update const existingObj = await this.deps.adapter.getObjectAsync(path); if (existingObj && existingObj.common && this.hasStatesChanged(commonOptions.states, existingObj.common.states)) { this.deps.log.debug(`[${this.duid}] Updating object definition for ${path} (states mapping changed)`); await this.deps.adapter.extendObject(path, { common: commonOptions as ioBroker.StateCommon, native: native }); return; } // Standard ensure (creates if not exists) await this.deps.ensureState(path, commonOptions as ioBroker.StateCommon, native); // Cast after validation } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Error", undefined, undefined, `Error in ensureState for ${path}: ${this.deps.adapter.errorMessage(e)}`, "error"); } } // --- Static Methods --- /** * Get registered feature class for model. * @param modelId Robot model identifier. * @returns Constructor or undefined. */ public static getRegisteredModelClass(modelId: string): FeatureClassConstructor | undefined { return modelRegistry.get(modelId); } /** * Get all registered model IDs. */ public static getRegisteredModels(): string[] { return Array.from(modelRegistry.keys()); } /** * Check if static feature is defined. * @param feature Feature enum key. */ public hasStaticFeature(feature: Feature): boolean { return this.config.staticFeatures.includes(feature); } public hasFeature(feature: Feature): boolean { return this.appliedFeatures.has(feature) || this.config.staticFeatures.includes(feature); } /** * Helper to safely access dynamic feature methods. * Encapsulates type casting for readability. */ protected getFeatureMethod(name: string): Function { // Safe access using keyof assertion const method = this[name as keyof this]; if (typeof method === "function") { return method as Function; } throw new Error(`Feature method '${name}' not found or is not a function.`); } // --- Command Parameter Interception --- /** * Allows feature handlers to provide/modify parameters for a command before sending. * Override this to implement logic like 'app_segment_clean' gathering segments from states. * @param method Command method name. * @param params Existing parameters passed from caller. */ public async getCommandParams(method: string, params?: unknown, id?: string): Promise<unknown> { void method; void id; return params; } public async onCommandResult(requestedMethod: string, finalMethod: string, response: unknown, params?: unknown): Promise<void> { void requestedMethod; void finalMethod; void response; void params; } // --- Data Update Methods (Unified Data Handling) --- /** * Fetch data and store in folder. * @param method API method. * @param params API parameters. * @param folder Target folder. * @param mapper Optional data mapper. */ protected async requestAndProcess(method: string, params: any[], folder: string, mapper?: (data: any) => Record<string, any> | Promise<Record<string, any>>): Promise<void> { try { const result = await this.deps.adapter.requestsHandler.sendRequest(this.duid, method, params); let resultObj: Record<string, unknown> | undefined; // Recursively unwrap single-element arrays (common in B01/Tuya responses) let unwrapped = result; while (Array.isArray(unwrapped) && unwrapped.length === 1) { unwrapped = unwrapped[0]; } if (typeof unwrapped === "object" && unwrapped !== null && !Array.isArray(unwrapped)) { resultObj = unwrapped as Record<string, unknown>; } if (resultObj) { // Apply mapper if (mapper) { resultObj = await mapper(resultObj); } await this.deps.ensureFolder(`Devices.${this.duid}.${folder}`); for (const key in resultObj) { await this.processResultKey(folder, key, resultObj[key]); } } } catch (e: unknown) { this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update ${folder} (method: ${method}): ${this.deps.adapter.errorMessage(e)}`, "warn"); } } /** * Process a single key from API result. */ protected async processResultKey(folder: string, key: string, val: unknown): Promise<void> { // Determine common options (type, role, unit) let common: Partial<ioBroker.StateCommon> | undefined; if (folder === "deviceStatus") { common = this.getCommonDeviceStates(key); } else if (folder === "cleaningInfo") { common = this.getCommonCleaningInfo(key); } else if (folder === "cleaningRecords" || folder.includes("records")) { common = this.getCommonCleaningRecords(key); } if (!common) { common = { name: key, type: typeof val as ioBroker.CommonType, read: true, write: false }; } // Handle Objects/Arrays by stringifying them so they don't crash the state if (typeof val === "object" && val !== null) { val = JSON.stringify(val); } // Formatting for timestamp keys only (clean_finish is 0/1 flag, not a timestamp) if ((key === "last_clean_t" || key === "begin" || key === "end") && typeof (val as any) === "number") { val = new Date((val as number) * 1000).toLocaleString(); common.type = "string"; // Update type to match new value } // Enforce type matching to keep the log clean if (common.type === "string" && typeof val !== "string") { val = String(val); } else if (common.type === "number" && typeof val !== "number") { val = Number(val); } else if (common.type === "boolean" && typeof val !== "boolean") { val = !!val; } const fullPath = `Devices.${this.duid}.${folder}.${key}`; if (!this.createdStates.has(fullPath)) { await this.deps.ensureState(fullPath, common); this.createdStates.add(fullPath); } await this.deps.adapter.setStateChanged(fullPath, { val: val as ioBroker.StateValue, ack: true }); } // --- Helper Methods --- private hasStatesChanged( newStates: Record<string, string> | string | string[] | undefined, oldStates: Record<string, string> | string | string[] | undefined ): boolean { if (!!newStates !== !!oldStates) return true; // One is defined, one is not if (!newStates || !oldStates) return false; // Both undefined return JSON.stringify(newStates) !== JSON.stringify(oldStates); } public async updateStatus(): Promise<void> { // Default for vacuums await this.requestAndProcess("get_prop", ["get_status"], "deviceStatus"); } public async updateConsumables(): Promise<void> { if (!this.hasFeature(Feature.Consumables)) return; await this.requestAndProcess("get_consumable", [], "consumables"); } public async updateNetworkInfo(): Promise<void> { // No feature guard: get_network_info is supported on all devices (V1/MQTT); B01 overrides and uses service.get_net_info. await this.requestAndProcess("get_network_info", [], "networkInfo"); } public async updateTimers(): Promise<void> { if (!this.hasFeature(Feature.Timers)) return; await this.requestAndProcess("get_timer", [], "timers"); await this.requestAndProcess("get_server_timer", [], "timers"); } public async updateFirmwareFeatures(): Promise<void> { if (!this.hasFeature(Feature.FirmwareInfo)) return; await this.requestAndProcess("get_fw_features", [], "firmwareFeatures"); } public async updateMultiMapsList(): Promise<void> { if (!this.hasFeature(Feature.MultiMap)) return; await this.requestAndProcess("get_multi_maps_list", [], "map"); } public async updateRoomMapping(): Promise<void> { if (!this.hasFeature(Feature.RoomMapping)) return; await this.requestAndProcess("get_room_mapping", [], "map"); } // Complex updates (override in subclasses) public async updateCleanSummary(): Promise<void> { // Default: no-op } public async updateMap(): Promise<void> { // Default: no-op } public async updateExtraStatus(): Promise<void> { // Default: no-op. Override for model-specifics. } public getCurrentMapIndex(): number { return 0; } public async getPhoto(imgId: string, type: number): Promise<any> { if (!this.hasFeature(Feature.GetPhoto)) { throw new Error("getPhoto feature not enabled for this device"); } try { const res = (await this.deps.adapter.requestsHandler.sendRequest( this.duid, "get_photo", { data_filter: { img_id: imgId, type: type, }, } )) as any; // PhotoManager handles the async 300/301 packets and resolves the promise with the final image. // The data returned here is the result of that resolution. // If the robot supports encryption (Cipher 1), PhotoManager now automatically handles RSA/AES decryption. const responseData = (res as any).buffer ? res : (res as any).data || res; return responseData; } catch (e: unknown) { this.deps.adapter.rLog("Requests", this.duid, "Error", this.protocolVersion || undefined, undefined, `[getPhoto] Failed: ${this.deps.adapter.errorMessage(e)}`, "error"); throw e; } } // --- Instance Getters for Constants (Abstract Declarations) --- // Implemented by subclasses to provide constants. public abstract getCommonConsumable(attribute: string | number): Partial<ioBroker.StateCommon> | undefined; public abstract isResetableConsumable(consumable: string): boolean; public abstract getCommonDeviceStates(attribute: string | number): Partial<ioBroker.StateCommon> | undefined; public abstract getCommonCleaningRecords(attribute: string | number): Partial<ioBroker.StateCommon> | undefined; public abstract getFirmwareFeatureName(featureID: string | number): string; public abstract getCommonCleaningInfo(attribute: string | number): Partial<ioBroker.StateCommon> | undefined; }