Home Assistant Unofficial Reference 2024.12.1
vacuum.py
Go to the documentation of this file.
1 """Support for Neato Connected Vacuums."""
2 
3 from __future__ import annotations
4 
5 from datetime import timedelta
6 import logging
7 from typing import Any
8 
9 from pybotvac import Robot
10 from pybotvac.exceptions import NeatoRobotException
11 import voluptuous as vol
12 
14  ATTR_STATUS,
15  STATE_CLEANING,
16  STATE_DOCKED,
17  STATE_ERROR,
18  STATE_RETURNING,
19  StateVacuumEntity,
20  VacuumEntityFeature,
21 )
22 from homeassistant.config_entries import ConfigEntry
23 from homeassistant.const import ATTR_MODE, STATE_IDLE, STATE_PAUSED
24 from homeassistant.core import HomeAssistant
25 from homeassistant.helpers import config_validation as cv, entity_platform
26 from homeassistant.helpers.device_registry import DeviceInfo
27 from homeassistant.helpers.entity_platform import AddEntitiesCallback
28 
29 from .const import (
30  ACTION,
31  ALERTS,
32  ERRORS,
33  MODE,
34  NEATO_LOGIN,
35  NEATO_MAP_DATA,
36  NEATO_PERSISTENT_MAPS,
37  NEATO_ROBOTS,
38  SCAN_INTERVAL_MINUTES,
39 )
40 from .entity import NeatoEntity
41 from .hub import NeatoHub
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
46 
47 ATTR_CLEAN_START = "clean_start"
48 ATTR_CLEAN_STOP = "clean_stop"
49 ATTR_CLEAN_AREA = "clean_area"
50 ATTR_CLEAN_BATTERY_START = "battery_level_at_clean_start"
51 ATTR_CLEAN_BATTERY_END = "battery_level_at_clean_end"
52 ATTR_CLEAN_SUSP_COUNT = "clean_suspension_count"
53 ATTR_CLEAN_SUSP_TIME = "clean_suspension_time"
54 ATTR_CLEAN_PAUSE_TIME = "clean_pause_time"
55 ATTR_CLEAN_ERROR_TIME = "clean_error_time"
56 ATTR_LAUNCHED_FROM = "launched_from"
57 
58 ATTR_NAVIGATION = "navigation"
59 ATTR_CATEGORY = "category"
60 ATTR_ZONE = "zone"
61 
62 
64  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
65 ) -> None:
66  """Set up Neato vacuum with config entry."""
67  neato: NeatoHub = hass.data[NEATO_LOGIN]
68  mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
69  persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS)
70  dev = [
71  NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)
72  for robot in hass.data[NEATO_ROBOTS]
73  ]
74 
75  if not dev:
76  return
77 
78  _LOGGER.debug("Adding vacuums %s", dev)
79  async_add_entities(dev, True)
80 
81  platform = entity_platform.async_get_current_platform()
82  assert platform is not None
83 
84  platform.async_register_entity_service(
85  "custom_cleaning",
86  {
87  vol.Optional(ATTR_MODE, default=2): cv.positive_int,
88  vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int,
89  vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int,
90  vol.Optional(ATTR_ZONE): cv.string,
91  },
92  "neato_custom_cleaning",
93  )
94 
95 
97  """Representation of a Neato Connected Vacuum."""
98 
99  _attr_supported_features = (
100  VacuumEntityFeature.BATTERY
101  | VacuumEntityFeature.PAUSE
102  | VacuumEntityFeature.RETURN_HOME
103  | VacuumEntityFeature.STOP
104  | VacuumEntityFeature.START
105  | VacuumEntityFeature.CLEAN_SPOT
106  | VacuumEntityFeature.STATE
107  | VacuumEntityFeature.MAP
108  | VacuumEntityFeature.LOCATE
109  )
110  _attr_name = None
111 
112  def __init__(
113  self,
114  neato: NeatoHub,
115  robot: Robot,
116  mapdata: dict[str, Any] | None,
117  persistent_maps: dict[str, Any] | None,
118  ) -> None:
119  """Initialize the Neato Connected Vacuum."""
120  super().__init__(robot)
121  self._attr_available_attr_available: bool = neato is not None
122  self._mapdata_mapdata = mapdata
123  self._robot_has_map: bool = self.robotrobot.has_persistent_maps
124  self._robot_maps_robot_maps = persistent_maps
125  self._robot_serial: str = self.robotrobot.serial
126  self._attr_unique_id: str = self.robotrobot.serial
127  self._status_state_status_state: str | None = None
128  self._state_state: dict[str, Any] | None = None
129  self._clean_time_start_clean_time_start: str | None = None
130  self._clean_time_stop_clean_time_stop: str | None = None
131  self._clean_area_clean_area: float | None = None
132  self._clean_battery_start_clean_battery_start: int | None = None
133  self._clean_battery_end_clean_battery_end: int | None = None
134  self._clean_susp_charge_count_clean_susp_charge_count: int | None = None
135  self._clean_susp_time_clean_susp_time: int | None = None
136  self._clean_pause_time_clean_pause_time: int | None = None
137  self._clean_error_time_clean_error_time: int | None = None
138  self._launched_from_launched_from: str | None = None
139  self._robot_boundaries_robot_boundaries: list = []
140  self._robot_stats_robot_stats: dict[str, Any] | None = None
141 
142  def update(self) -> None:
143  """Update the states of Neato Vacuums."""
144  _LOGGER.debug("Running Neato Vacuums update for '%s'", self.entity_identity_id)
145  try:
146  if self._robot_stats_robot_stats is None:
147  self._robot_stats_robot_stats = self.robotrobot.get_general_info().json().get("data")
148  except NeatoRobotException:
149  _LOGGER.warning("Couldn't fetch robot information of %s", self.entity_identity_id)
150 
151  try:
152  self._state_state = self.robotrobot.state
153  except NeatoRobotException as ex:
154  if self._attr_available_attr_available: # print only once when available
155  _LOGGER.error(
156  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
157  )
158  self._state_state = None
159  self._attr_available_attr_available = False
160  return
161 
162  if self._state_state is None:
163  return
164  self._attr_available_attr_available = True
165  _LOGGER.debug("self._state=%s", self._state_state)
166  if "alert" in self._state_state:
167  robot_alert = ALERTS.get(self._state_state["alert"])
168  else:
169  robot_alert = None
170  if self._state_state["state"] == 1:
171  if self._state_state["details"]["isCharging"]:
172  self._attr_state_attr_state = STATE_DOCKED
173  self._status_state_status_state = "Charging"
174  elif (
175  self._state_state["details"]["isDocked"]
176  and not self._state_state["details"]["isCharging"]
177  ):
178  self._attr_state_attr_state = STATE_DOCKED
179  self._status_state_status_state = "Docked"
180  else:
181  self._attr_state_attr_state = STATE_IDLE
182  self._status_state_status_state = "Stopped"
183 
184  if robot_alert is not None:
185  self._status_state_status_state = robot_alert
186  elif self._state_state["state"] == 2:
187  if robot_alert is None:
188  self._attr_state_attr_state = STATE_CLEANING
189  self._status_state_status_state = (
190  f"{MODE.get(self._state['cleaning']['mode'])} "
191  f"{ACTION.get(self._state['action'])}"
192  )
193  if (
194  "boundary" in self._state_state["cleaning"]
195  and "name" in self._state_state["cleaning"]["boundary"]
196  ):
197  self._status_state_status_state += (
198  f" {self._state['cleaning']['boundary']['name']}"
199  )
200  else:
201  self._status_state_status_state = robot_alert
202  elif self._state_state["state"] == 3:
203  self._attr_state_attr_state = STATE_PAUSED
204  self._status_state_status_state = "Paused"
205  elif self._state_state["state"] == 4:
206  self._attr_state_attr_state = STATE_ERROR
207  self._status_state_status_state = ERRORS.get(self._state_state["error"])
208 
209  self._attr_battery_level_attr_battery_level = self._state_state["details"]["charge"]
210 
211  if self._mapdata_mapdata is None or not self._mapdata_mapdata.get(self._robot_serial, {}).get(
212  "maps", []
213  ):
214  return
215 
216  mapdata: dict[str, Any] = self._mapdata_mapdata[self._robot_serial]["maps"][0]
217  self._clean_time_start_clean_time_start = mapdata["start_at"]
218  self._clean_time_stop_clean_time_stop = mapdata["end_at"]
219  self._clean_area_clean_area = mapdata["cleaned_area"]
220  self._clean_susp_charge_count_clean_susp_charge_count = mapdata["suspended_cleaning_charging_count"]
221  self._clean_susp_time_clean_susp_time = mapdata["time_in_suspended_cleaning"]
222  self._clean_pause_time_clean_pause_time = mapdata["time_in_pause"]
223  self._clean_error_time_clean_error_time = mapdata["time_in_error"]
224  self._clean_battery_start_clean_battery_start = mapdata["run_charge_at_start"]
225  self._clean_battery_end_clean_battery_end = mapdata["run_charge_at_end"]
226  self._launched_from_launched_from = mapdata["launched_from"]
227 
228  if (
229  self._robot_has_map
230  and self._state_state
231  and self._state_state["availableServices"]["maps"] != "basic-1"
232  and self._robot_maps_robot_maps
233  ):
234  allmaps: dict = self._robot_maps_robot_maps[self._robot_serial]
235  _LOGGER.debug(
236  "Found the following maps for '%s': %s", self.entity_identity_id, allmaps
237  )
238  self._robot_boundaries_robot_boundaries = [] # Reset boundaries before refreshing boundaries
239  for maps in allmaps:
240  try:
241  robot_boundaries = self.robotrobot.get_map_boundaries(maps["id"]).json()
242  except NeatoRobotException as ex:
243  _LOGGER.error(
244  "Could not fetch map boundaries for '%s': %s",
245  self.entity_identity_id,
246  ex,
247  )
248  return
249 
250  _LOGGER.debug(
251  "Boundaries for robot '%s' in map '%s': %s",
252  self.entity_identity_id,
253  maps["name"],
254  robot_boundaries,
255  )
256  if "boundaries" in robot_boundaries["data"]:
257  self._robot_boundaries_robot_boundaries += robot_boundaries["data"]["boundaries"]
258  _LOGGER.debug(
259  "List of boundaries for '%s': %s",
260  self.entity_identity_id,
261  self._robot_boundaries_robot_boundaries,
262  )
263 
264  @property
265  def extra_state_attributes(self) -> dict[str, Any]:
266  """Return the state attributes of the vacuum cleaner."""
267  data: dict[str, Any] = {}
268 
269  if self._status_state_status_state is not None:
270  data[ATTR_STATUS] = self._status_state_status_state
271  if self._clean_time_start_clean_time_start is not None:
272  data[ATTR_CLEAN_START] = self._clean_time_start_clean_time_start
273  if self._clean_time_stop_clean_time_stop is not None:
274  data[ATTR_CLEAN_STOP] = self._clean_time_stop_clean_time_stop
275  if self._clean_area_clean_area is not None:
276  data[ATTR_CLEAN_AREA] = self._clean_area_clean_area
277  if self._clean_susp_charge_count_clean_susp_charge_count is not None:
278  data[ATTR_CLEAN_SUSP_COUNT] = self._clean_susp_charge_count_clean_susp_charge_count
279  if self._clean_susp_time_clean_susp_time is not None:
280  data[ATTR_CLEAN_SUSP_TIME] = self._clean_susp_time_clean_susp_time
281  if self._clean_pause_time_clean_pause_time is not None:
282  data[ATTR_CLEAN_PAUSE_TIME] = self._clean_pause_time_clean_pause_time
283  if self._clean_error_time_clean_error_time is not None:
284  data[ATTR_CLEAN_ERROR_TIME] = self._clean_error_time_clean_error_time
285  if self._clean_battery_start_clean_battery_start is not None:
286  data[ATTR_CLEAN_BATTERY_START] = self._clean_battery_start_clean_battery_start
287  if self._clean_battery_end_clean_battery_end is not None:
288  data[ATTR_CLEAN_BATTERY_END] = self._clean_battery_end_clean_battery_end
289  if self._launched_from_launched_from is not None:
290  data[ATTR_LAUNCHED_FROM] = self._launched_from_launched_from
291 
292  return data
293 
294  @property
295  def device_info(self) -> DeviceInfo:
296  """Device info for neato robot."""
297  device_info = self._attr_device_info
298  if self._robot_stats_robot_stats:
299  device_info["manufacturer"] = self._robot_stats_robot_stats["battery"]["vendor"]
300  device_info["model"] = self._robot_stats_robot_stats["model"]
301  device_info["sw_version"] = self._robot_stats_robot_stats["firmware"]
302  return device_info
303 
304  def start(self) -> None:
305  """Start cleaning or resume cleaning."""
306  if self._state_state:
307  try:
308  if self._state_state["state"] == 1:
309  self.robotrobot.start_cleaning()
310  elif self._state_state["state"] == 3:
311  self.robotrobot.resume_cleaning()
312  except NeatoRobotException as ex:
313  _LOGGER.error(
314  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
315  )
316 
317  def pause(self) -> None:
318  """Pause the vacuum."""
319  try:
320  self.robotrobot.pause_cleaning()
321  except NeatoRobotException as ex:
322  _LOGGER.error(
323  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
324  )
325 
326  def return_to_base(self, **kwargs: Any) -> None:
327  """Set the vacuum cleaner to return to the dock."""
328  try:
329  if self._attr_state_attr_state == STATE_CLEANING:
330  self.robotrobot.pause_cleaning()
331  self._attr_state_attr_state = STATE_RETURNING
332  self.robotrobot.send_to_base()
333  except NeatoRobotException as ex:
334  _LOGGER.error(
335  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
336  )
337 
338  def stop(self, **kwargs: Any) -> None:
339  """Stop the vacuum cleaner."""
340  try:
341  self.robotrobot.stop_cleaning()
342  except NeatoRobotException as ex:
343  _LOGGER.error(
344  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
345  )
346 
347  def locate(self, **kwargs: Any) -> None:
348  """Locate the robot by making it emit a sound."""
349  try:
350  self.robotrobot.locate()
351  except NeatoRobotException as ex:
352  _LOGGER.error(
353  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
354  )
355 
356  def clean_spot(self, **kwargs: Any) -> None:
357  """Run a spot cleaning starting from the base."""
358  try:
359  self.robotrobot.start_spot_cleaning()
360  except NeatoRobotException as ex:
361  _LOGGER.error(
362  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
363  )
364 
366  self, mode: str, navigation: str, category: str, zone: str | None = None
367  ) -> None:
368  """Zone cleaning service call."""
369  boundary_id = None
370  if zone is not None:
371  for boundary in self._robot_boundaries_robot_boundaries:
372  if zone in boundary["name"]:
373  boundary_id = boundary["id"]
374  if boundary_id is None:
375  _LOGGER.error(
376  "Zone '%s' was not found for the robot '%s'", zone, self.entity_identity_id
377  )
378  return
379  _LOGGER.debug(
380  "Start cleaning zone '%s' with robot %s", zone, self.entity_identity_id
381  )
382 
383  self._attr_state_attr_state = STATE_CLEANING
384  try:
385  self.robotrobot.start_cleaning(mode, navigation, category, boundary_id)
386  except NeatoRobotException as ex:
387  _LOGGER.error(
388  "Neato vacuum connection error for '%s': %s", self.entity_identity_id, ex
389  )
None __init__(self, NeatoHub neato, Robot robot, dict[str, Any]|None mapdata, dict[str, Any]|None persistent_maps)
Definition: vacuum.py:118
None neato_custom_cleaning(self, str mode, str navigation, str category, str|None zone=None)
Definition: vacuum.py:367
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: vacuum.py:65