Home Assistant Unofficial Reference 2024.12.1
vacuum.py
Go to the documentation of this file.
1 """Support for Ecovacs Ecovacs Vacuums."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import logging
7 from typing import TYPE_CHECKING, Any
8 
9 from deebot_client.capabilities import Capabilities, DeviceType
10 from deebot_client.device import Device
11 from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent
12 from deebot_client.models import CleanAction, CleanMode, Room, State
13 import sucks
14 
16  STATE_CLEANING,
17  STATE_DOCKED,
18  STATE_ERROR,
19  STATE_IDLE,
20  STATE_PAUSED,
21  STATE_RETURNING,
22  StateVacuumEntity,
23  StateVacuumEntityDescription,
24  VacuumEntityFeature,
25 )
26 from homeassistant.core import HomeAssistant, SupportsResponse
27 from homeassistant.exceptions import ServiceValidationError
28 from homeassistant.helpers import entity_platform
29 from homeassistant.helpers.entity_platform import AddEntitiesCallback
30 from homeassistant.helpers.icon import icon_for_battery_level
31 from homeassistant.util import slugify
32 
33 from . import EcovacsConfigEntry
34 from .const import DOMAIN
35 from .entity import EcovacsEntity, EcovacsLegacyEntity
36 from .util import get_name_key
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 ATTR_ERROR = "error"
41 ATTR_COMPONENT_PREFIX = "component_"
42 
43 SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
44 
45 
47  hass: HomeAssistant,
48  config_entry: EcovacsConfigEntry,
49  async_add_entities: AddEntitiesCallback,
50 ) -> None:
51  """Set up the Ecovacs vacuums."""
52 
53  controller = config_entry.runtime_data
54  vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [
55  EcovacsVacuum(device)
56  for device in controller.devices
57  if device.capabilities.device_type is DeviceType.VACUUM
58  ]
59  vacuums.extend(
60  [EcovacsLegacyVacuum(device) for device in controller.legacy_devices]
61  )
62  _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
63  async_add_entities(vacuums)
64 
65  platform = entity_platform.async_get_current_platform()
66  platform.async_register_entity_service(
67  SERVICE_RAW_GET_POSITIONS,
68  None,
69  "async_raw_get_positions",
70  supports_response=SupportsResponse.ONLY,
71  )
72 
73 
75  """Legacy Ecovacs vacuums."""
76 
77  _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH]
78  _attr_supported_features = (
79  VacuumEntityFeature.BATTERY
80  | VacuumEntityFeature.RETURN_HOME
81  | VacuumEntityFeature.CLEAN_SPOT
82  | VacuumEntityFeature.STOP
83  | VacuumEntityFeature.START
84  | VacuumEntityFeature.LOCATE
85  | VacuumEntityFeature.STATE
86  | VacuumEntityFeature.SEND_COMMAND
87  | VacuumEntityFeature.FAN_SPEED
88  )
89 
90  async def async_added_to_hass(self) -> None:
91  """Set up the event listeners now that hass is ready."""
92  self._event_listeners.append(
93  self.devicedevice.statusEvents.subscribe(
94  lambda _: self.schedule_update_ha_stateschedule_update_ha_state()
95  )
96  )
97  self._event_listeners.append(
98  self.devicedevice.batteryEvents.subscribe(
99  lambda _: self.schedule_update_ha_stateschedule_update_ha_state()
100  )
101  )
102  self._event_listeners.append(
103  self.devicedevice.lifespanEvents.subscribe(
104  lambda _: self.schedule_update_ha_stateschedule_update_ha_state()
105  )
106  )
107  self._event_listeners.append(self.devicedevice.errorEvents.subscribe(self.on_erroron_error))
108 
109  def on_error(self, error: str) -> None:
110  """Handle an error event from the robot.
111 
112  This will not change the entity's state. If the error caused the state
113  to change, that will come through as a separate on_status event
114  """
115  if error in ["no_error", sucks.ERROR_CODES["100"]]:
116  self.errorerror = None
117  else:
118  self.errorerror = error
119 
120  self.hasshass.bus.fire(
121  "ecovacs_error", {"entity_id": self.entity_identity_id, "error": error}
122  )
123  self.schedule_update_ha_stateschedule_update_ha_state()
124 
125  @property
126  def state(self) -> str | None:
127  """Return the state of the vacuum cleaner."""
128  if self.errorerror is not None:
129  return STATE_ERROR
130 
131  if self.devicedevice.is_cleaning:
132  return STATE_CLEANING
133 
134  if self.devicedevice.is_charging:
135  return STATE_DOCKED
136 
137  if self.devicedevice.vacuum_status == sucks.CLEAN_MODE_STOP:
138  return STATE_IDLE
139 
140  if self.devicedevice.vacuum_status == sucks.CHARGE_MODE_RETURNING:
141  return STATE_RETURNING
142 
143  return None
144 
145  @property
146  def battery_level(self) -> int | None:
147  """Return the battery level of the vacuum cleaner."""
148  if self.devicedevice.battery_status is not None:
149  return self.devicedevice.battery_status * 100 # type: ignore[no-any-return]
150 
151  return None
152 
153  @property
154  def battery_icon(self) -> str:
155  """Return the battery icon for the vacuum cleaner."""
156  return icon_for_battery_level(
157  battery_level=self.battery_levelbattery_levelbattery_level, charging=self.devicedevice.is_charging
158  )
159 
160  @property
161  def fan_speed(self) -> str | None:
162  """Return the fan speed of the vacuum cleaner."""
163  return self.devicedevice.fan_speed # type: ignore[no-any-return]
164 
165  @property
166  def extra_state_attributes(self) -> dict[str, Any]:
167  """Return the device-specific state attributes of this vacuum."""
168  data: dict[str, Any] = {}
169  data[ATTR_ERROR] = self.errorerror
170 
171  # these attributes are deprecated and can be removed in 2025.2
172  for key, val in self.devicedevice.components.items():
173  attr_name = ATTR_COMPONENT_PREFIX + key
174  data[attr_name] = int(val * 100)
175 
176  return data
177 
178  def return_to_base(self, **kwargs: Any) -> None:
179  """Set the vacuum cleaner to return to the dock."""
180 
181  self.devicedevice.run(sucks.Charge())
182 
183  def start(self, **kwargs: Any) -> None:
184  """Turn the vacuum on and start cleaning."""
185 
186  self.devicedevice.run(sucks.Clean())
187 
188  def stop(self, **kwargs: Any) -> None:
189  """Stop the vacuum cleaner."""
190 
191  self.devicedevice.run(sucks.Stop())
192 
193  def clean_spot(self, **kwargs: Any) -> None:
194  """Perform a spot clean-up."""
195 
196  self.devicedevice.run(sucks.Spot())
197 
198  def locate(self, **kwargs: Any) -> None:
199  """Locate the vacuum cleaner."""
200 
201  self.devicedevice.run(sucks.PlaySound())
202 
203  def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
204  """Set fan speed."""
205  if self.statestatestatestatestatestate == STATE_CLEANING:
206  self.devicedevice.run(sucks.Clean(mode=self.devicedevice.clean_status, speed=fan_speed))
207 
209  self,
210  command: str,
211  params: dict[str, Any] | list[Any] | None = None,
212  **kwargs: Any,
213  ) -> None:
214  """Send a command to a vacuum cleaner."""
215  self.devicedevice.run(sucks.VacBotCommand(command, params))
216 
218  self,
219  ) -> None:
220  """Get bot and chargers positions."""
222  translation_domain=DOMAIN,
223  translation_key="vacuum_raw_get_positions_not_supported",
224  )
225 
226 
227 _STATE_TO_VACUUM_STATE = {
228  State.IDLE: STATE_IDLE,
229  State.CLEANING: STATE_CLEANING,
230  State.RETURNING: STATE_RETURNING,
231  State.DOCKED: STATE_DOCKED,
232  State.ERROR: STATE_ERROR,
233  State.PAUSED: STATE_PAUSED,
234 }
235 
236 _ATTR_ROOMS = "rooms"
237 
238 
240  EcovacsEntity[Capabilities],
241  StateVacuumEntity,
242 ):
243  """Ecovacs vacuum."""
244 
245  _unrecorded_attributes = frozenset({_ATTR_ROOMS})
246 
247  _attr_supported_features = (
248  VacuumEntityFeature.PAUSE
249  | VacuumEntityFeature.STOP
250  | VacuumEntityFeature.RETURN_HOME
251  | VacuumEntityFeature.BATTERY
252  | VacuumEntityFeature.SEND_COMMAND
253  | VacuumEntityFeature.LOCATE
254  | VacuumEntityFeature.STATE
255  | VacuumEntityFeature.START
256  )
257 
258  entity_description = StateVacuumEntityDescription(
259  key="vacuum", translation_key="vacuum", name=None
260  )
261 
262  def __init__(self, device: Device) -> None:
263  """Initialize the vacuum."""
264  super().__init__(device, device.capabilities)
265 
266  self._rooms_rooms: list[Room] = []
267 
268  if fan_speed := self._capability_capability.fan_speed:
269  self._attr_supported_features_attr_supported_features |= VacuumEntityFeature.FAN_SPEED
270  self._attr_fan_speed_list_attr_fan_speed_list = [
271  get_name_key(level) for level in fan_speed.types
272  ]
273 
274  async def async_added_to_hass(self) -> None:
275  """Set up the event listeners now that hass is ready."""
276  await super().async_added_to_hass()
277 
278  async def on_battery(event: BatteryEvent) -> None:
279  self._attr_battery_level_attr_battery_level = event.value
280  self.async_write_ha_stateasync_write_ha_state()
281 
282  async def on_rooms(event: RoomsEvent) -> None:
283  self._rooms_rooms = event.rooms
284  self.async_write_ha_stateasync_write_ha_state()
285 
286  async def on_status(event: StateEvent) -> None:
287  self._attr_state_attr_state = _STATE_TO_VACUUM_STATE[event.state]
288  self.async_write_ha_stateasync_write_ha_state()
289 
290  self._subscribe_subscribe(self._capability_capability.battery.event, on_battery)
291  self._subscribe_subscribe(self._capability_capability.state.event, on_status)
292 
293  if self._capability_capability.fan_speed:
294 
295  async def on_fan_speed(event: FanSpeedEvent) -> None:
296  self._attr_fan_speed_attr_fan_speed = get_name_key(event.speed)
297  self.async_write_ha_stateasync_write_ha_state()
298 
299  self._subscribe_subscribe(self._capability_capability.fan_speed.event, on_fan_speed)
300 
301  if map_caps := self._capability_capability.map:
302  self._subscribe_subscribe(map_caps.rooms.event, on_rooms)
303 
304  @property
305  def extra_state_attributes(self) -> Mapping[str, Any] | None:
306  """Return entity specific state attributes.
307 
308  Implemented by platform classes. Convention for attribute names
309  is lowercase snake_case.
310  """
311  rooms: dict[str, Any] = {}
312  for room in self._rooms_rooms:
313  # convert room name to snake_case to meet the convention
314  room_name = slugify(room.name)
315  room_values = rooms.get(room_name)
316  if room_values is None:
317  rooms[room_name] = room.id
318  elif isinstance(room_values, list):
319  room_values.append(room.id)
320  else:
321  # Convert from int to list
322  rooms[room_name] = [room_values, room.id]
323 
324  return {
325  _ATTR_ROOMS: rooms,
326  }
327 
328  async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
329  """Set fan speed."""
330  if TYPE_CHECKING:
331  assert self._capability_capability.fan_speed
332  await self._device_device.execute_command(self._capability_capability.fan_speed.set(fan_speed))
333 
334  async def async_return_to_base(self, **kwargs: Any) -> None:
335  """Set the vacuum cleaner to return to the dock."""
336  await self._device_device.execute_command(self._capability_capability.charge.execute())
337 
338  async def async_stop(self, **kwargs: Any) -> None:
339  """Stop the vacuum cleaner."""
340  await self._clean_command_clean_command(CleanAction.STOP)
341 
342  async def async_pause(self) -> None:
343  """Pause the vacuum cleaner."""
344  await self._clean_command_clean_command(CleanAction.PAUSE)
345 
346  async def async_start(self) -> None:
347  """Start the vacuum cleaner."""
348  await self._clean_command_clean_command(CleanAction.START)
349 
350  async def _clean_command(self, action: CleanAction) -> None:
351  await self._device_device.execute_command(
352  self._capability_capability.clean.action.command(action)
353  )
354 
355  async def async_locate(self, **kwargs: Any) -> None:
356  """Locate the vacuum cleaner."""
357  await self._device_device.execute_command(self._capability_capability.play_sound.execute())
358 
360  self,
361  command: str,
362  params: dict[str, Any] | list[Any] | None = None,
363  **kwargs: Any,
364  ) -> None:
365  """Send a command to a vacuum cleaner."""
366  _LOGGER.debug("async_send_command %s with %s", command, params)
367  if params is None:
368  params = {}
369  elif isinstance(params, list):
371  translation_domain=DOMAIN,
372  translation_key="vacuum_send_command_params_dict",
373  )
374 
375  if command in ["spot_area", "custom_area"]:
376  if params is None:
378  translation_domain=DOMAIN,
379  translation_key="vacuum_send_command_params_required",
380  translation_placeholders={"command": command},
381  )
382  if self._capability_capability.clean.action.area is None:
383  info = self._device_device.device_info
384  name = info.get("nick", info["name"])
386  translation_domain=DOMAIN,
387  translation_key="vacuum_send_command_area_not_supported",
388  translation_placeholders={"name": name},
389  )
390 
391  if command in "spot_area":
392  await self._device_device.execute_command(
393  self._capability_capability.clean.action.area(
394  CleanMode.SPOT_AREA,
395  str(params["rooms"]),
396  params.get("cleanings", 1),
397  )
398  )
399  elif command == "custom_area":
400  await self._device_device.execute_command(
401  self._capability_capability.clean.action.area(
402  CleanMode.CUSTOM_AREA,
403  str(params["coordinates"]),
404  params.get("cleanings", 1),
405  )
406  )
407  else:
408  await self._device_device.execute_command(
409  self._capability_capability.custom.set(command, params)
410  )
411 
413  self,
414  ) -> dict[str, Any]:
415  """Get bot and chargers positions."""
416  _LOGGER.debug("async_raw_get_positions")
417 
418  if not (map_cap := self._capability_capability.map) or not (
419  position_commands := map_cap.position.get
420  ):
422  translation_domain=DOMAIN,
423  translation_key="vacuum_raw_get_positions_not_supported",
424  )
425 
426  return await self._device_device.execute_command(position_commands[0])
None _subscribe(self, type[EventT] event_type, Callable[[EventT], Coroutine[Any, Any, None]] callback)
Definition: entity.py:87
None set_fan_speed(self, str fan_speed, **Any kwargs)
Definition: vacuum.py:203
None send_command(self, str command, dict[str, Any]|list[Any]|None params=None, **Any kwargs)
Definition: vacuum.py:213
None async_send_command(self, str command, dict[str, Any]|list[Any]|None params=None, **Any kwargs)
Definition: vacuum.py:364
None async_set_fan_speed(self, str fan_speed, **Any kwargs)
Definition: vacuum.py:328
Mapping[str, Any]|None extra_state_attributes(self)
Definition: vacuum.py:305
None _clean_command(self, CleanAction action)
Definition: vacuum.py:350
None schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1244
None async_setup_entry(HomeAssistant hass, EcovacsConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: vacuum.py:50
str icon_for_battery_level(int|None battery_level=None, bool charging=False)
Definition: icon.py:169
int run(RuntimeConfig runtime_config)
Definition: runner.py:146