iobroker.roborock
Version:
1,173 lines (1,030 loc) • 35.5 kB
text/typescript
import { MapManager } from "../../../map/MapManager";
import { RoborockLocales } from "../../../roborock_locales";
import { BaseDeviceFeatures, DeviceModelConfig, FeatureDependencies } from "../../baseDeviceFeatures";
import { Feature } from "../../features.enum";
import { ADAPTER_ERROR_MAPPING } from "../adapterErrorMapping";
import { B01ConsumableService } from "../services/B01ConsumableService";
import { B01ControlService } from "../services/B01ControlService";
import { B01MapService } from "../services/B01MapService";
import { StationService } from "../services/StationService";
import { VACUUM_CONSTANTS } from "../vacuumConstants";
import type { B01Variant } from "../../../b01Variant";
import type { B01DeviceStatus } from "../../../map/b01/types";
import deviceDataSet = require("../../../../../lib/protocols/q7_dataset.json");
export class B01BaseVacuumFeatures extends BaseDeviceFeatures {
// B01-specific properties
protected mapManager: MapManager;
protected locales: RoborockLocales;
// Services
protected consumableService: B01ConsumableService;
protected stationService: StationService;
protected lastMapUpdate = 0;
protected mapService: B01MapService;
protected controlService: B01ControlService;
public readonly b01Variant: B01Variant;
protected mappedRooms: Array<{ id: number; name: string }> | null = null;
protected async cleanupVariantCommandObjects(): Promise<void> {
return;
}
protected shouldPrecreateConsumableResetStates(): boolean {
return true;
}
protected getVariantCommonDeviceStates(_attribute: string | number): Partial<ioBroker.StateCommon> | undefined {
void _attribute;
return undefined;
}
private async createConsumableResetStateObjects(): Promise<void> {
await this.deps.ensureFolder(`Devices.${this.duid}.resetConsumables`);
const resets: Record<string, string> = {
"reset_main_brush": "Reset Main Brush",
"reset_side_brush": "Reset Side Brush",
"reset_filter": "Reset Filter"
};
for (const [id, name] of Object.entries(resets)) {
await this.deps.ensureState(`Devices.${this.duid}.resetConsumables.${id}`, {
name: name,
type: "boolean",
role: "button",
def: false,
read: false,
write: true
});
}
}
protected async deleteObjectIfExists(id: string, recursive = false): Promise<void> {
const existing = await this.deps.adapter.getObjectAsync(id);
if (!existing) return;
await this.deps.adapter.delObjectAsync(id, recursive ? { recursive: true } : undefined);
}
constructor(
dependencies: FeatureDependencies,
duid: string,
robotModel: string,
config: DeviceModelConfig,
profile?: unknown, // Accept profile to match legacy subclasses, but ignore it
b01Variant: B01Variant = "Q7"
) {
super(dependencies, duid, robotModel, config);
void profile;
this.b01Variant = b01Variant;
this.mapManager = this.deps.adapter.mapManager;
this.locales = new RoborockLocales(deviceDataSet);
this.consumableService = new B01ConsumableService(this.deps, this.duid);
this.stationService = new StationService(this.deps, this.duid);
// Initialize Services
this.mapService = new B01MapService(dependencies, duid, (rooms) => this.setMappedRooms(rooms));
this.controlService = new B01ControlService();
this.deps.adapter.rLog("System", this.duid, "Info", "B01", undefined, `Constructing ${this.constructor.name} for ${robotModel} (${b01Variant})`, "info");
}
/**
* Configures the command set for B01 devices.
* @see test/unit/features_specification.test.ts for the B01 command and property specification.
*/
public override async setupProtocolFeatures(): Promise<void> {
this.deps.adapter.rLog("System", this.duid, "Debug", "B01", undefined, "Configuring B01 Command Set...", "debug");
// 1. CLEAR all inherited base commands. B01 uses its own protocol.
this.commands = {};
// 2. Add properties for prop.get
const properties = [
"wind", "water", "clean_mode", "status", "error_code", "battery",
"clean_time", "clean_area", "map_status", "dock_status", "water_box_level_off", "dust_collection_status"
];
const propStates: Record<string, string> = {};
properties.forEach(p => propStates[p] = p);
this.addCommand("prop.get", {
type: "string",
role: "text",
name: "Property Get",
def: "status",
states: propStates
});
// 3. Status Control (Buttons)
this.addCommand("app_start", {
type: "boolean",
role: "button",
name: this.deps.adapter.translations["app_start"] || "Start Cleaning",
def: false
});
this.addCommand("app_stop", {
type: "boolean",
role: "button",
name: this.deps.adapter.translations["app_stop"] || "Stop",
def: false
});
this.addCommand("app_pause", {
type: "boolean",
role: "button",
name: this.deps.adapter.translations["app_pause"] || "Pause Cleaning",
def: false
});
this.addCommand("app_charge", {
type: "boolean",
role: "button",
name: this.deps.adapter.translations["app_charge"] || "Return to Dock",
def: false
});
this.addCommand("find_me", {
type: "boolean",
role: "button",
name: this.deps.adapter.translations["find_me"] || "Find Me",
def: false
});
// 3b. Segment / Room cleaning (B01: service.set_room_clean with room_ids)
this.addCommand("app_segment_clean", {
type: "boolean",
role: "button",
name: this.deps.adapter.translations["app_segment_clean"] || "Segment Cleaning",
def: false
});
// 4. Fan Power (wind)
this.addCommand("wind", {
type: "number",
role: "value",
name: this.locales.getNameAll("wind"),
def: 2,
states: {
1: "Quiet",
2: "Balanced",
3: "Turbo",
4: "Max",
5: "Max+"
}
});
// 5. Water Level (water)
this.addCommand("water", {
type: "number",
role: "value",
name: this.locales.getNameAll("water"),
def: 1,
states: {
1: "Low",
2: "Medium",
3: "High"
}
});
this.addCommand("clean_path_preference", {
type: "number",
role: "value",
name: this.locales.getNameAll("clean_path_preference"),
def: 0,
states: {
0: "Standard",
1: "Fast",
2: "Deep"
}
});
// 12. Update Map
this.addCommand("update_map", {
type: "boolean",
role: "button",
name: "Update Map",
def: false
});
// 13. Consumable Resets are handled in createCommandObjects and initializeDeviceData
// 14. Additional B01 Commands
this.addCommand("child_lock", {
type: "boolean",
role: "switch",
name: this.locales.getNameAll("child_lock"),
def: false
});
this.addCommand("carpet_turbo", {
type: "boolean",
role: "switch",
name: this.locales.getNameAll("carpet_turbo"),
def: false
});
this.addCommand("light_mode", {
type: "boolean",
role: "switch",
name: this.locales.getNameAll("light_mode"),
def: true
});
this.addCommand("green_laser", {
type: "boolean",
role: "switch",
name: this.locales.getNameAll("green_laser"),
def: true
});
this.addCommand("repeat_state", {
type: "number",
role: "value",
name: this.locales.getNameAll("repeat_state"),
def: 0,
states: { 0: "Off", 1: "On" } // Assuming 0/1 typical for repeat
});
// 10. Robot Mode (Verified in logs: 0=Vacuum, 1=Vac&Mop, 2=Mop)
this.addCommand("mode", {
type: "number",
role: "value",
name: this.locales.getNameAll("mode"),
def: 0,
states: {
0: "Vacuum",
1: "Vacuum & Mop",
2: "Mop"
}
});
const cmds = Object.keys(this.commands);
this.deps.adapter.rLog("System", this.duid, "Info", "B01", undefined, `B01 Protocol Enforced. Commands in memory: ${cmds.join(", ")}`, "info");
}
public override async createCommandObjects(): Promise<void> {
await this.cleanupVariantCommandObjects();
await super.createCommandObjects();
if (this.shouldPrecreateConsumableResetStates()) {
await this.createConsumableResetStateObjects();
}
}
/**
* Allows feature handlers to provide/modify parameters for a command before sending.
* B01 uses this to map individual command states to prop.set or service calls.
*/
public override async getCommandParams(method: string, params?: unknown, id?: string): Promise<unknown> {
void id;
if (method === "app_segment_clean") {
const namespace = this.deps.adapter.namespace;
const currentMapIdState = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.deviceStatus.current_map_id`);
const currentMapId = (currentMapIdState && typeof currentMapIdState.val === "number" && currentMapIdState.val > 0)
? currentMapIdState.val
: null;
const pattern = currentMapId != null
? `${namespace}.Devices.${this.duid}.floors.${currentMapId}.*`
: `${namespace}.Devices.${this.duid}.floors.*.*`;
const states = await this.deps.adapter.getStatesAsync(pattern);
const roomIds: number[] = [];
const nonRoomKeys = new Set(["name", "mapFlag", "load", "map_id"]);
if (states) {
for (const [stateId, state] of Object.entries(states)) {
if (!state || (state.val !== true && state.val !== "true" && state.val !== 1)) continue;
const parts = stateId.split(".");
const lastSegment = parts[parts.length - 1];
if (nonRoomKeys.has(lastSegment)) continue;
const rid = Number(lastSegment);
if (!isNaN(rid) && rid >= 0 && !roomIds.includes(rid)) {
roomIds.push(rid);
}
}
}
if (roomIds.length > 0) {
this.deps.adapter.rLog("System", this.duid, "Info", "B01", undefined, `Starting room cleaning for rooms: ${roomIds.join(", ")}`, "info");
} else {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, "No rooms selected for segment cleaning. Start full clean (room_ids: []).", "warn");
}
return {
method: "service.set_room_clean",
params: { clean_type: 0, ctrl_value: 1, room_ids: roomIds }
};
}
// Delegate all other command parameter mapping to the Control Service
return this.controlService.getCommandParams(method, params);
}
public override async initializeDeviceData(): Promise<void> {
await this.updateStatus();
await this.updateMap();
await this.updateMultiMapsList();
await this.updateRoomMapping();
await this.deps.adapter.checkForNewFirmware(this.duid);
await Promise.all([
this.updateFirmwareFeatures(),
this.updateExtraStatus(),
this.updateNetworkInfo(),
this.updateTimers()
]);
}
public override async updateMultiMapsList(): Promise<void> {
if (this.mapService) {
await this.mapService.updateMultiMapsList();
}
}
public async updateRoomMapping(): Promise<void> {
if (this.mappedRooms && this.mapService) {
await this.mapService.updateRoomMapping(this.mappedRooms, {
refreshFloors: true
});
}
}
public setMappedRooms(rooms: Array<{ id: number; name: string }>): void {
this.mappedRooms = rooms;
// When map arrives async, create room selection states under current floor
void this.updateRoomMapping();
}
public override async updateStatus(): Promise<void> {
const props = VACUUM_CONSTANTS.b01StatusProps;
let resultObj: Record<string, any> | undefined;
try {
const result = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "prop.get", { property: props });
if (Array.isArray(result) && result.length === props.length) {
resultObj = {};
props.forEach((key: string, index: number) => {
resultObj![key] = result[index];
});
} else if (typeof result === "object" && result !== null) {
resultObj = result;
}
if (resultObj) {
if (!this.runtimeDetectionComplete) {
await this.detectAndApplyRuntimeFeatures(resultObj);
}
await this.processStatus(resultObj);
const c = await this.deps.adapter.getStateAsync(`Devices.${this.duid}.cleaningInfo.clean_count`);
this.deps.adapter.rLog("System", this.duid, "Debug", "B01", undefined, `status=${resultObj.status ?? "?"}, clean_count=${c?.val ?? "?"}`, "debug");
}
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Warn", undefined, undefined, `Failed to update status (B01): ${e.message}`, "warn");
throw e;
}
}
public override async updateConsumables(data?: unknown): Promise<void> {
await this.consumableService.updateConsumables(data);
}
public override async updateTimers(): Promise<void> {
await super.updateTimers();
}
// Override processStatus to apply B01 specific conversions (dm² to m²)
protected async processStatus(resultObj: Record<string, unknown>): Promise<void> {
this.updateMapManagerRuntimeStatus(resultObj);
// Handle docking station status separately
const dssValue = resultObj["dss"];
const washStatus = resultObj["wash_status"];
const washPhase = resultObj["wash_phase"];
if (dssValue !== undefined || washStatus !== undefined || washPhase !== undefined) {
if (dssValue !== undefined) delete resultObj["dss"];
await this.updateDockingStationStatus({
dss: dssValue !== undefined ? Number(dssValue) : undefined,
washStatus: washStatus !== undefined ? Number(washStatus) : undefined,
washPhase: washPhase !== undefined ? Number(washPhase) : undefined
});
}
// Map B01 specific keys to standard ioBroker states for compatibility
await this.deps.ensureFolder(`Devices.${this.duid}.deviceStatus`);
for (const key in resultObj) {
await this.processStatusProperty(key, resultObj[key]);
}
}
private updateMapManagerRuntimeStatus(resultObj: Readonly<Record<string, unknown>>): void {
const nextStatus: Partial<B01DeviceStatus> = {};
const stateValue = resultObj.status ?? resultObj.state;
const workModeValue = resultObj.work_mode ?? resultObj.workMode ?? resultObj.clean_task_type;
const cleanModeValue = resultObj.clean_mode ?? resultObj.cleanMode ?? resultObj.mode;
const faultValue = resultObj.fault ?? resultObj.error_code;
const dustCollectValue = resultObj.dust_action ?? resultObj.dust_collection_status;
const batteryValue = resultObj.battery;
if (stateValue !== undefined && stateValue !== null) nextStatus.deviceState = Number(stateValue);
if (workModeValue !== undefined && workModeValue !== null) nextStatus.deviceWorkMode = Number(workModeValue);
if (cleanModeValue !== undefined && cleanModeValue !== null) nextStatus.deviceCleanMode = Number(cleanModeValue);
if (faultValue !== undefined && faultValue !== null) nextStatus.deviceFault = Number(faultValue);
if (batteryValue !== undefined && batteryValue !== null) nextStatus.deviceBattery = Number(batteryValue);
if (dustCollectValue !== undefined && dustCollectValue !== null) {
nextStatus.isDustCollect = dustCollectValue === 1 || dustCollectValue === true || dustCollectValue === "1";
}
if (Object.keys(nextStatus).length > 0) {
this.mapManager.updateB01DeviceStatus(this.duid, nextStatus);
}
}
protected async processStatusProperty(key: string, inputVal: unknown): Promise<void> {
let val = inputVal;
if (typeof val === "string") {
const trimmed = val.trim();
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && (trimmed.endsWith("}") || trimmed.endsWith("]"))) {
try {
val = JSON.parse(trimmed);
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Debug", this.protocolVersion || undefined, undefined, `Failed to parse JSON property ${key}: ${e.message}`, "debug");
}
}
}
// Formatting for timestamps only (clean_finish is 0/1 flag, not a timestamp)
if (key === "last_clean_t" && typeof val === "number") {
val = this.deps.adapter.formatRoborockDate(val);
}
// Get definition or create default
const def = this.getCommonDeviceStates(key);
let type: ioBroker.CommonType = typeof val as ioBroker.CommonType;
if (type === "object" && val !== null) type = "object";
if (key === "last_clean_t" && typeof val === "string") type = "string";
const common: any = def ? { ...def } : { name: key, type: type, role: "value", read: true, write: false };
// Enrich with defaults if missing
if (!common.name) common.name = key;
if (!common.role) common.role = "value";
if (common.read === undefined) common.read = true;
if (common.write === undefined) common.write = false;
// Manual Metadata Overrides - Removed hardcoded EN strings to use getNameAll translations
if (key === "cleaning_time" || key === "real_clean_time" || key === "total_clean_time") {
common.role = "value.interval";
common.unit = key === "cleaning_time" ? "min" : "s";
}
// Serialize complex objects
if (typeof val === "object" && val !== null) {
val = JSON.stringify(val);
}
// B01 Area/Time Conversion
if (["clean_time", "cleaning_time", "total_clean_time"].includes(key)) {
// cleaning_time is in minutes; no conversion.
// last_clean_t might be timestamp or duration? usually timestamp if clean_finish.
const numericVal = Number(val as number | string);
val = isNaN(numericVal) ? 0 : numericVal;
} else if (["clean_area", "cleaning_area", "last_clean_area", "total_clean_area"].includes(key)) {
// B01 sends dm² (e.g. 2129 -> 21.29 m²)
const numericVal = Number(((val as number) / 100).toFixed(2));
val = isNaN(numericVal) ? 0 : numericVal;
}
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;
}
// Use ensureState for optimized change detection to prevent write storms
const stateId = `Devices.${this.duid}.deviceStatus.${key}`;
await this.deps.ensureState(stateId, common);
this.deps.adapter.setStateChanged(`Devices.${this.duid}.deviceStatus.${key}`, { val: val as ioBroker.StateValue, ack: true });
}
public override async updateMap(): Promise<void> {
await this.mapService.updateMap();
}
public override async updateCleanSummary(): Promise<void> {
await this.mapService.updateCleanSummary();
}
public async getCleaningRecordMap(startTime: number, recordDetails?: unknown): Promise<{ mapBase64CleanUncropped: string; mapBase64: string; mapBase64Truncated: string; mapData: string } | null> {
return this.mapService.getCleaningRecordMap(startTime, recordDetails);
}
protected async processCleanSummary(result: unknown): Promise<void> {
await this.mapService.processCleanSummary(result);
}
.DeviceFeature(Feature.DockingStationStatus)
protected async initDockingStationStatus(): Promise<void> {
await this.stationService.initDockingStationStatus();
// B01 Specific Station States
await this.deps.ensureState(`Devices.${this.duid}.dockingStationStatus.washingTaskStatus`, {
name: "Washing Task Status",
type: "number",
role: "value",
read: true,
write: false,
states: {
"0": "Idle",
"1": "Washing",
"2": "Deep Washing",
"3": "Drying",
"4": "Completed",
"5": "Paused",
"6": "Error"
}
});
await this.deps.ensureState(`Devices.${this.duid}.dockingStationStatus.washingMode`, {
name: "Washing Mode",
type: "number",
role: "value",
read: true,
write: false,
states: {
"0": "Standard",
"1": "Deep"
}
});
}
/**
* Updates docking station status. Dynamic: if the device sends dss/washStatus/washPhase,
* we ensure folder/states exist (lazy init) and update. No feature-guard.
*/
public async updateDockingStationStatus(status: { dss?: number, washStatus?: number, washPhase?: number }): Promise<void> {
const hasAny = status.dss !== undefined || status.washStatus !== undefined || status.washPhase !== undefined;
if (!hasAny) return;
await this.initDockingStationStatus(); // idempotent: ensure folder + states (incl. B01 washingTaskStatus/washingMode)
if (status.dss !== undefined) {
await this.stationService.updateDockingStationStatus(status.dss);
}
if (status.washStatus !== undefined) {
await this.deps.adapter.setStateChanged(`Devices.${this.duid}.dockingStationStatus.washingTaskStatus`, { val: status.washStatus, ack: true });
}
if (status.washPhase !== undefined) {
await this.deps.adapter.setStateChanged(`Devices.${this.duid}.dockingStationStatus.washingMode`, { val: status.washPhase, ack: true });
}
}
public override async detectAndApplyRuntimeFeatures(statusData: Readonly<Record<string, unknown>>): Promise<boolean> {
let changed = false;
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);
}
changed = true;
}
if (!this.runtimeDetectionComplete) {
this.runtimeDetectionComplete = true;
changed = true;
}
return changed;
}
protected override getDynamicFeatures(): Set<Feature> {
return new Set<Feature>(); // B01 does not use bitfield feature flags
}
public override getCommonDeviceStates(attribute: string | number): Partial<ioBroker.StateCommon> | undefined {
// Map B01 properties to readable states
const lang = this.deps.adapter.language || "en";
const variantState = this.getVariantCommonDeviceStates(attribute);
if (variantState) return variantState;
// Fan Power (wind)
if (attribute === "wind" || attribute === "fan_power") {
// Map B01 (1-5) to Standard (101-105) for text retrieval
const b01FanMap: Record<number, number> = { 1: 101, 2: 102, 3: 103, 4: 104, 5: 105 };
const states: Record<number, string> = {};
for (const [b01Val, stdVal] of Object.entries(b01FanMap)) {
states[parseInt(b01Val)] = this.locales.getFanPowerText(stdVal, lang);
}
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: states
};
}
// Water Level (water)
if (attribute === "water" || attribute === "water_box_mode") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: this.locales.getWaterBoxModeText(200, lang), // Off
1: this.locales.getWaterBoxModeText(201, lang), // Low
2: this.locales.getWaterBoxModeText(202, lang), // Medium
3: this.locales.getWaterBoxModeText(203, lang) // High
}
};
}
// Status
if (attribute === "status" || attribute === "state") {
const states: Record<number, string> = {
0: "Unknown",
1: "Initiating",
2: "Sleeping",
3: "Idle",
4: "Remote Control",
5: "Cleaning",
6: "Returning Dock",
7: "Manual Mode",
8: "Charging",
9: "Charging Error",
10: "Paused",
11: "Spot Cleaning",
12: "In Error",
13: "Shutting Down",
14: "Updating",
15: "Docking",
16: "Go To",
17: "Zone Clean",
18: "Room Clean",
22: "Emptying Dust Container",
23: "Washing Mop",
26: "Going to Wash Mop",
28: "In Call",
29: "Mapping",
100: "Fully Charged"
};
// Override with dataset translations if available
for (const code in states) {
const key = this.locales.getStatusKey(code);
if (key) {
states[Number(code)] = this.locales.getText(key, lang);
}
}
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: states
};
}
// Cleaning Stats
if (attribute === "clean_time" || attribute === "cleaning_time" || attribute === "total_clean_time") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
unit: "min"
};
}
if (attribute === "clean_area" || attribute === "cleaning_area" || attribute === "last_clean_area") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
unit: "m²"
};
}
if (attribute === "quantity") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
unit: "%"
};
}
if (attribute === "real_clean_time") {
return {
type: "number",
role: "value.interval",
name: this.locales.getNameAll(String(attribute)),
unit: "s"
};
}
if (attribute === "clean_finish") {
return {
type: "number",
role: "value",
name: this.locales.getNameAll(String(attribute)),
};
}
if (attribute === "last_clean_t") {
return {
type: "string",
name: this.locales.getNameAll(String(attribute))
};
}
// Error Codes
if (attribute === "error_code" || attribute === "fault") {
const faultCodes = this.locales.getErrorCodes();
const standardErrors = VACUUM_CONSTANTS.errorCodes;
const states: Record<string, string> = {};
// Use standard firmware error range (1-30) as the foundation before applying model-specific overrides to ensure basic faults are covered
// Provide localized fallback if available
const standardErrorsToUse: Record<number, string> = { ...(standardErrors as unknown as Record<number, string>) };
// Apply Adapter Error Mapping for specific language using TranslationManager
// This replaces the legacy s7_maxv_dataset.json fallback
for (const [codeStr, key] of Object.entries(ADAPTER_ERROR_MAPPING)) {
const code = Number(codeStr);
// TranslationManager uses adapter.language internally via init()
const translated = this.deps.adapter.translationManager.get(key);
if (translated && translated !== key) {
standardErrorsToUse[code] = translated;
}
}
// Add standard error codes (with fallback)
for (const [code, desc] of Object.entries(standardErrorsToUse)) {
if (typeof desc === "string") {
states[code] = desc;
}
}
// 2. Add/Overlay model-specific fault codes from dataset
faultCodes.forEach((code) => {
const entry = this.locales.getErrorText(code, lang);
let text = "";
if (typeof entry === "string") {
text = entry;
} else if (entry && typeof entry === "object") {
const obj = entry as any;
text = obj.summary || obj.title || "";
}
// Check if the resolved text matches a standardized placeholder format to avoid overwriting default labels
const isPlaceholder = text === `F_${code}`;
if (isPlaceholder || !text) {
// Skip overwriting if it's a placeholder or empty
} else {
states[code] = text;
}
});
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: states,
};
}
// 5. Dock Status
if (attribute === "dock_status") {
const states: Record<number, string> = {
0: "Undocked",
1: "Docking",
2: "Docked",
3: "Leaving Dock",
255: "Unknown"
};
// TODO: Add dataset mapping for dock_status if possible
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: states
};
}
// 6. Dust Collection Status
if (attribute === "dust_collection_status") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Idle",
1: "Collecting",
2: "Collection Weaker",
3: "Collection Failed",
4: "Dustbin Full"
}
};
}
// 7. Map Status
if (attribute === "map_status") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Unmapped",
1: "Mapped",
2: "Mapping",
3: "Loading"
}
};
}
// 8. Charge State
if (attribute === "charge_state") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Charging",
1: "Not Charging",
2: "Fully Charged",
3: "Charge Failed"
}
};
}
// 9. Work Mode
if (attribute === "work_mode") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Standard",
1: "Custom",
2: "Silent"
}
};
}
// 10. Robot Mode
if (attribute === "mode") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Vacuum",
1: "Vacuum & Mop",
2: "Mop"
}
};
}
// 10. Tank State (Water Box)
if (attribute === "tank_state") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Installed",
1: "Removed",
2: "Empty",
3: "Unknown"
}
};
}
// 11. Sweep Type (Mop Route)
if (attribute === "sweep_type" || attribute === "mop_mode") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: this.locales.getMopModeText(300, lang) || "Standard",
1: this.locales.getMopModeText(301, lang) || "Deep",
300: this.locales.getMopModeText(300, lang),
301: this.locales.getMopModeText(301, lang),
302: this.locales.getMopModeText(302, lang),
303: this.locales.getMopModeText(303, lang)
}
};
}
// 12. Cloth State (Mop Pad)
if (attribute === "cloth_state") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: this.locales.getClothStateText(0, lang),
1: this.locales.getClothStateText(1, lang),
2: this.locales.getClothStateText(2, lang)
}
};
}
// 13. Multi Floor
if (attribute === "multi_floor") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Disabled",
1: "Enabled"
}
};
}
// 14. Quiet Is Open (DND Mode)
if (attribute === "quiet_is_open") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Off",
1: "On"
}
};
}
// 15. Dust Collection Frequency
if (attribute === "dust_frequency") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Smart",
1: "Low",
2: "Medium",
3: "High",
4: "Never"
}
};
}
// 16. Clean Path Preference
if (attribute === "clean_path_preference") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Standard",
1: "Fast"
}
};
}
// 17. Repeat State
if (attribute === "repeat_state") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Off",
1: "On"
}
};
}
// 18. Dust Action
if (attribute === "dust_action") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Idle",
1: "Emptying"
}
};
}
// 19. Build Map
if (attribute === "build_map") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Off",
1: "On"
}
};
}
// 20. Map Num
if (attribute === "map_num") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
unit: "maps"
};
}
// 22. Custom Type
if (attribute === "custom_type") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
0: "Off",
1: "On"
}
};
}
// 23. Add Sweep Status
if (attribute === "add_sweep_status") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
states: {
"-1": "Unknown",
0: this.locales.getText("common_off", lang),
1: this.locales.getText("common_on", lang)
}
};
}
// 24. Language
if (attribute === "language") {
return {
type: "string",
name: this.locales.getNameAll(String(attribute)),
};
}
// 25. Time Zone
if (attribute === "time_zone") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
};
}
if (attribute === "time_zone_info") {
return {
type: "string",
name: this.locales.getNameAll(String(attribute)),
};
}
// 26. Recommend (B01 sends object e.g. { sill, wall, room_id[] }; store as JSON string)
if (attribute === "recommend") {
return {
type: "string",
name: this.locales.getNameAll(String(attribute)),
};
}
// 27. PU Charging
if (attribute === "pu_charging") {
return {
type: "number",
name: this.locales.getNameAll(String(attribute)),
};
}
// Default fallback using getNameAll
const keyName = String(attribute);
const translatedName = this.locales.getNameAll(keyName);
if (translatedName !== keyName) {
return {
name: translatedName,
type: "mixed",
role: "value",
read: true,
write: false
};
}
return undefined;
}
public override async updateNetworkInfo(): Promise<void> {
try {
// B01: Request via service.get_net_info? Or just rely on prop.get("net_status")?
// prop.get "net_status" usually just gives connected state.
// Let's try service.get_network_info first as it's common.
const res = await this.deps.adapter.requestsHandler.sendRequest(this.duid, "service.get_net_info", {});
if (!res) return;
// Typically returns { ssid, ip, mac, rssi... }
const info = Array.isArray(res) ? res[0] : res;
if (!info) return;
await this.deps.ensureFolder(`Devices.${this.duid}.networkInfo`);
for (const key in info) {
const rawType = typeof info[key];
const type = (rawType === "object" ? "mixed" : rawType) as ioBroker.CommonType;
await this.deps.ensureState(`Devices.${this.duid}.networkInfo.${key}`, {
name: key,
type: type,
read: true,
write: false
});
await this.deps.adapter.setStateChanged(`Devices.${this.duid}.networkInfo.${key}`, { val: info[key], ack: true });
}
} catch (e: any) {
this.deps.adapter.rLog("System", this.duid, "Warn", "B01", undefined, `Failed to update network info: ${e.message}`, "warn");
}
}
protected static readonly MAPPED_CLEAN_SUMMARY: Record<string, string> = { 0: "clean_time", 1: "clean_area", 2: "clean_count", 3: "records", record_list: "records" };
/**
* Override to handle B01 specific unit conversions (mm² -> m², s -> min)
*/
protected override async processResultKey(folder: string, key: string, val: unknown): Promise<void> {
if (typeof val === "number") {
// Time: seconds -> minutes
if (key === "clean_time" || key === "cleaning_time" || key === "total_clean_time") {
val = Math.round(val / 60);
} else if (key === "clean_area" || key === "cleaning_area" || key === "last_clean_area" || key === "total_clean_area") {
val = Math.round(val / 1000000);
}
}
await super.processResultKey(folder, key, val);
}
public getCommonConsumable(attribute: string | number): Partial<ioBroker.StateCommon> | undefined {
return VACUUM_CONSTANTS.consumables[attribute as keyof typeof VACUUM_CONSTANTS.consumables] as Partial<ioBroker.StateCommon>;
}
public isResetableConsumable(consumable: string): boolean {
return VACUUM_CONSTANTS.resetConsumables.has(consumable);
}
public getCommonCleaningInfo(attribute: string | number): Partial<ioBroker.StateCommon> | undefined {
return (VACUUM_CONSTANTS.cleaningInfo 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] || `FeatureID_${featureID}`;
}
}