"""Support for Ecovacs Ecovacs Vacuums.""" from __future__ import annotations from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from deebot_client.capabilities import Capabilities, DeviceType from deebot_client.device import Device from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent from deebot_client.models import CleanAction, CleanMode, Room, State import sucks from homeassistant.components.vacuum import ( STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_IDLE, STATE_PAUSED, STATE_RETURNING, StateVacuumEntity, StateVacuumEntityDescription, VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, SupportsResponse from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from . import EcovacsConfigEntry from .const import DOMAIN from .entity import EcovacsEntity from .util import get_name_key _LOGGER = logging.getLogger(__name__) ATTR_ERROR = "error" ATTR_COMPONENT_PREFIX = "component_" SERVICE_RAW_GET_POSITIONS = "raw_get_positions" async def async_setup_entry( hass: HomeAssistant, config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecovacs vacuums.""" controller = config_entry.runtime_data vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [ EcovacsVacuum(device) for device in controller.devices if device.capabilities.device_type is DeviceType.VACUUM ] for device in controller.legacy_devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) vacuums.append(EcovacsLegacyVacuum(device)) _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_RAW_GET_POSITIONS, {}, "async_raw_get_positions", supports_response=SupportsResponse.ONLY, ) class EcovacsLegacyVacuum(StateVacuumEntity): """Legacy Ecovacs vacuums.""" _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] _attr_should_poll = False _attr_supported_features = ( VacuumEntityFeature.BATTERY | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP | VacuumEntityFeature.START | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATE | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.FAN_SPEED ) def __init__(self, device: sucks.VacBot) -> None: """Initialize the Ecovacs Vacuum.""" self.device = device vacuum = self.device.vacuum self.error: str | None = None self._attr_unique_id = vacuum["did"] self._attr_name = vacuum.get("nick", vacuum["did"]) async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" self.device.statusEvents.subscribe(lambda _: self.schedule_update_ha_state()) self.device.batteryEvents.subscribe(lambda _: self.schedule_update_ha_state()) self.device.lifespanEvents.subscribe(lambda _: self.schedule_update_ha_state()) self.device.errorEvents.subscribe(self.on_error) def on_error(self, error: str) -> None: """Handle an error event from the robot. This will not change the entity's state. If the error caused the state to change, that will come through as a separate on_status event """ if error in ["no_error", sucks.ERROR_CODES["100"]]: self.error = None else: self.error = error self.hass.bus.fire( "ecovacs_error", {"entity_id": self.entity_id, "error": error} ) self.schedule_update_ha_state() @property def state(self) -> str | None: """Return the state of the vacuum cleaner.""" if self.error is not None: return STATE_ERROR if self.device.is_cleaning: return STATE_CLEANING if self.device.is_charging: return STATE_DOCKED if self.device.vacuum_status == sucks.CLEAN_MODE_STOP: return STATE_IDLE if self.device.vacuum_status == sucks.CHARGE_MODE_RETURNING: return STATE_RETURNING return None @property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" if self.device.battery_status is not None: return self.device.battery_status * 100 # type: ignore[no-any-return] return None @property def battery_icon(self) -> str: """Return the battery icon for the vacuum cleaner.""" return icon_for_battery_level( battery_level=self.battery_level, charging=self.device.is_charging ) @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self.device.fan_speed # type: ignore[no-any-return] @property def extra_state_attributes(self) -> dict[str, Any]: """Return the device-specific state attributes of this vacuum.""" data: dict[str, Any] = {} data[ATTR_ERROR] = self.error for key, val in self.device.components.items(): attr_name = ATTR_COMPONENT_PREFIX + key data[attr_name] = int(val * 100) return data def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" self.device.run(sucks.Charge()) def start(self, **kwargs: Any) -> None: """Turn the vacuum on and start cleaning.""" self.device.run(sucks.Clean()) def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" self.device.run(sucks.Stop()) def clean_spot(self, **kwargs: Any) -> None: """Perform a spot clean-up.""" self.device.run(sucks.Spot()) def locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" self.device.run(sucks.PlaySound()) def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" if self.state == STATE_CLEANING: self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) def send_command( self, command: str, params: dict[str, Any] | list[Any] | None = None, **kwargs: Any, ) -> None: """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) async def async_raw_get_positions( self, ) -> None: """Get bot and chargers positions.""" raise ServiceValidationError( translation_domain=DOMAIN, translation_key="vacuum_raw_get_positions_not_supported", ) _STATE_TO_VACUUM_STATE = { State.IDLE: STATE_IDLE, State.CLEANING: STATE_CLEANING, State.RETURNING: STATE_RETURNING, State.DOCKED: STATE_DOCKED, State.ERROR: STATE_ERROR, State.PAUSED: STATE_PAUSED, } _ATTR_ROOMS = "rooms" class EcovacsVacuum( EcovacsEntity[Capabilities], StateVacuumEntity, ): """Ecovacs vacuum.""" _unrecorded_attributes = frozenset({_ATTR_ROOMS}) _attr_supported_features = ( VacuumEntityFeature.PAUSE | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.BATTERY | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATE | VacuumEntityFeature.START ) entity_description = StateVacuumEntityDescription( key="vacuum", translation_key="vacuum", name=None ) def __init__(self, device: Device) -> None: """Initialize the vacuum.""" super().__init__(device, device.capabilities) self._rooms: list[Room] = [] if fan_speed := self._capability.fan_speed: self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED self._attr_fan_speed_list = [ get_name_key(level) for level in fan_speed.types ] async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" await super().async_added_to_hass() async def on_battery(event: BatteryEvent) -> None: self._attr_battery_level = event.value self.async_write_ha_state() async def on_rooms(event: RoomsEvent) -> None: self._rooms = event.rooms self.async_write_ha_state() async def on_status(event: StateEvent) -> None: self._attr_state = _STATE_TO_VACUUM_STATE[event.state] self.async_write_ha_state() self._subscribe(self._capability.battery.event, on_battery) self._subscribe(self._capability.state.event, on_status) if self._capability.fan_speed: async def on_fan_speed(event: FanSpeedEvent) -> None: self._attr_fan_speed = get_name_key(event.speed) self.async_write_ha_state() self._subscribe(self._capability.fan_speed.event, on_fan_speed) if map_caps := self._capability.map: self._subscribe(map_caps.rooms.event, on_rooms) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. Implemented by platform classes. Convention for attribute names is lowercase snake_case. """ rooms: dict[str, Any] = {} for room in self._rooms: # convert room name to snake_case to meet the convention room_name = slugify(room.name) room_values = rooms.get(room_name) if room_values is None: rooms[room_name] = room.id elif isinstance(room_values, list): room_values.append(room.id) else: # Convert from int to list rooms[room_name] = [room_values, room.id] return { _ATTR_ROOMS: rooms, } async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" if TYPE_CHECKING: assert self._capability.fan_speed await self._device.execute_command(self._capability.fan_speed.set(fan_speed)) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" await self._device.execute_command(self._capability.charge.execute()) async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" await self._clean_command(CleanAction.STOP) async def async_pause(self) -> None: """Pause the vacuum cleaner.""" await self._clean_command(CleanAction.PAUSE) async def async_start(self) -> None: """Start the vacuum cleaner.""" await self._clean_command(CleanAction.START) async def _clean_command(self, action: CleanAction) -> None: await self._device.execute_command( self._capability.clean.action.command(action) ) async def async_locate(self, **kwargs: Any) -> None: """Locate the vacuum cleaner.""" await self._device.execute_command(self._capability.play_sound.execute()) async def async_send_command( self, command: str, params: dict[str, Any] | list[Any] | None = None, **kwargs: Any, ) -> None: """Send a command to a vacuum cleaner.""" _LOGGER.debug("async_send_command %s with %s", command, params) if params is None: params = {} elif isinstance(params, list): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="vacuum_send_command_params_dict", ) if command in ["spot_area", "custom_area"]: if params is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="vacuum_send_command_params_required", translation_placeholders={"command": command}, ) if self._capability.clean.action.area is None: info = self._device.device_info name = info.get("nick", info["name"]) raise ServiceValidationError( translation_domain=DOMAIN, translation_key="vacuum_send_command_area_not_supported", translation_placeholders={"name": name}, ) if command in "spot_area": await self._device.execute_command( self._capability.clean.action.area( CleanMode.SPOT_AREA, str(params["rooms"]), params.get("cleanings", 1), ) ) elif command == "custom_area": await self._device.execute_command( self._capability.clean.action.area( CleanMode.CUSTOM_AREA, str(params["coordinates"]), params.get("cleanings", 1), ) ) else: await self._device.execute_command( self._capability.custom.set(command, params) ) async def async_raw_get_positions( self, ) -> dict[str, Any]: """Get bot and chargers positions.""" _LOGGER.debug("async_raw_get_positions") if not (map_cap := self._capability.map) or not ( position_commands := map_cap.position.get ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="vacuum_raw_get_positions_not_supported", ) return await self._device.execute_command(position_commands[0])