mirror of https://github.com/home-assistant/core
427 lines
14 KiB
Python
427 lines
14 KiB
Python
"""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, EcovacsLegacyEntity
|
|
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
|
|
]
|
|
vacuums.extend(
|
|
[EcovacsLegacyVacuum(device) for device in controller.legacy_devices]
|
|
)
|
|
_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,
|
|
None,
|
|
"async_raw_get_positions",
|
|
supports_response=SupportsResponse.ONLY,
|
|
)
|
|
|
|
|
|
class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
|
"""Legacy Ecovacs vacuums."""
|
|
|
|
_attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
|
|
_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
|
|
)
|
|
|
|
async def async_added_to_hass(self) -> None:
|
|
"""Set up the event listeners now that hass is ready."""
|
|
self._event_listeners.append(
|
|
self.device.statusEvents.subscribe(
|
|
lambda _: self.schedule_update_ha_state()
|
|
)
|
|
)
|
|
self._event_listeners.append(
|
|
self.device.batteryEvents.subscribe(
|
|
lambda _: self.schedule_update_ha_state()
|
|
)
|
|
)
|
|
self._event_listeners.append(
|
|
self.device.lifespanEvents.subscribe(
|
|
lambda _: self.schedule_update_ha_state()
|
|
)
|
|
)
|
|
self._event_listeners.append(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
|
|
|
|
# these attributes are deprecated and can be removed in 2025.2
|
|
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])
|