Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Xiaomi Miio."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Coroutine
7 from dataclasses import dataclass
8 from datetime import timedelta
9 import logging
10 from typing import Any
11 
12 from miio import (
13  AirFresh,
14  AirFreshA1,
15  AirFreshT2017,
16  AirHumidifier,
17  AirHumidifierMiot,
18  AirHumidifierMjjsq,
19  AirPurifier,
20  AirPurifierMiot,
21  CleaningDetails,
22  CleaningSummary,
23  ConsumableStatus,
24  Device as MiioDevice,
25  DeviceException,
26  DNDStatus,
27  Fan,
28  Fan1C,
29  FanMiot,
30  FanP5,
31  FanZA5,
32  RoborockVacuum,
33  Timer,
34  VacuumStatus,
35 )
36 from miio.gateway.gateway import GatewayException
37 
38 from homeassistant.config_entries import ConfigEntry
39 from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform
40 from homeassistant.core import HomeAssistant, callback
41 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
42 from homeassistant.helpers import device_registry as dr, entity_registry as er
43 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
44 
45 from .const import (
46  ATTR_AVAILABLE,
47  CONF_FLOW_TYPE,
48  CONF_GATEWAY,
49  DOMAIN,
50  KEY_COORDINATOR,
51  KEY_DEVICE,
52  MODEL_AIRFRESH_A1,
53  MODEL_AIRFRESH_T2017,
54  MODEL_FAN_1C,
55  MODEL_FAN_P5,
56  MODEL_FAN_P9,
57  MODEL_FAN_P10,
58  MODEL_FAN_P11,
59  MODEL_FAN_P18,
60  MODEL_FAN_ZA5,
61  MODELS_AIR_MONITOR,
62  MODELS_FAN,
63  MODELS_FAN_MIIO,
64  MODELS_HUMIDIFIER,
65  MODELS_HUMIDIFIER_MIIO,
66  MODELS_HUMIDIFIER_MIOT,
67  MODELS_HUMIDIFIER_MJJSQ,
68  MODELS_LIGHT,
69  MODELS_PURIFIER_MIOT,
70  MODELS_SWITCH,
71  MODELS_VACUUM,
72  ROBOROCK_GENERIC,
73  ROCKROBO_GENERIC,
74  AuthException,
75  SetupException,
76 )
77 from .gateway import ConnectXiaomiGateway
78 
79 _LOGGER = logging.getLogger(__name__)
80 
81 POLLING_TIMEOUT_SEC = 10
82 UPDATE_INTERVAL = timedelta(seconds=15)
83 
84 GATEWAY_PLATFORMS = [
85  Platform.ALARM_CONTROL_PANEL,
86  Platform.LIGHT,
87  Platform.SENSOR,
88  Platform.SWITCH,
89 ]
90 SWITCH_PLATFORMS = [Platform.SWITCH]
91 FAN_PLATFORMS = [
92  Platform.BINARY_SENSOR,
93  Platform.BUTTON,
94  Platform.FAN,
95  Platform.NUMBER,
96  Platform.SELECT,
97  Platform.SENSOR,
98  Platform.SWITCH,
99 ]
100 HUMIDIFIER_PLATFORMS = [
101  Platform.BINARY_SENSOR,
102  Platform.HUMIDIFIER,
103  Platform.NUMBER,
104  Platform.SELECT,
105  Platform.SENSOR,
106  Platform.SWITCH,
107 ]
108 LIGHT_PLATFORMS = [Platform.LIGHT]
109 VACUUM_PLATFORMS = [
110  Platform.BINARY_SENSOR,
111  Platform.SENSOR,
112  Platform.BUTTON,
113  Platform.VACUUM,
114 ]
115 AIR_MONITOR_PLATFORMS = [Platform.AIR_QUALITY, Platform.SENSOR]
116 
117 MODEL_TO_CLASS_MAP = {
118  MODEL_FAN_1C: Fan1C,
119  MODEL_FAN_P9: FanMiot,
120  MODEL_FAN_P10: FanMiot,
121  MODEL_FAN_P11: FanMiot,
122  MODEL_FAN_P18: FanMiot,
123  MODEL_FAN_P5: FanP5,
124  MODEL_FAN_ZA5: FanZA5,
125 }
126 
127 
128 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
129  """Set up the Xiaomi Miio components from a config entry."""
130  hass.data.setdefault(DOMAIN, {})
131  if entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY:
132  await async_setup_gateway_entry(hass, entry)
133  return True
134 
135  return bool(
136  entry.data[CONF_FLOW_TYPE] != CONF_DEVICE
137  or await async_setup_device_entry(hass, entry)
138  )
139 
140 
141 @callback
142 def get_platforms(config_entry):
143  """Return the platforms belonging to a config_entry."""
144  model = config_entry.data[CONF_MODEL]
145  flow_type = config_entry.data[CONF_FLOW_TYPE]
146 
147  if flow_type == CONF_GATEWAY:
148  return GATEWAY_PLATFORMS
149  if flow_type == CONF_DEVICE:
150  if model in MODELS_SWITCH:
151  return SWITCH_PLATFORMS
152  if model in MODELS_HUMIDIFIER:
153  return HUMIDIFIER_PLATFORMS
154  if model in MODELS_FAN:
155  return FAN_PLATFORMS
156  if model in MODELS_LIGHT:
157  return LIGHT_PLATFORMS
158  for vacuum_model in MODELS_VACUUM:
159  if model.startswith(vacuum_model):
160  return VACUUM_PLATFORMS
161  for air_monitor_model in MODELS_AIR_MONITOR:
162  if model.startswith(air_monitor_model):
163  return AIR_MONITOR_PLATFORMS
164  _LOGGER.error(
165  (
166  "Unsupported device found! Please create an issue at "
167  "https://github.com/syssi/xiaomi_airpurifier/issues "
168  "and provide the following data: %s"
169  ),
170  model,
171  )
172  return []
173 
174 
175 def _async_update_data_default(hass, device):
176  async def update():
177  """Fetch data from the device using async_add_executor_job."""
178 
179  async def _async_fetch_data():
180  """Fetch data from the device."""
181  async with asyncio.timeout(POLLING_TIMEOUT_SEC):
182  state = await hass.async_add_executor_job(device.status)
183  _LOGGER.debug("Got new state: %s", state)
184  return state
185 
186  try:
187  return await _async_fetch_data()
188  except DeviceException as ex:
189  if getattr(ex, "code", None) != -9999:
190  raise UpdateFailed(ex) from ex
191  _LOGGER.error(
192  "Got exception while fetching the state, trying again: %s", ex
193  )
194  # Try to fetch the data a second time after error code -9999
195  try:
196  return await _async_fetch_data()
197  except DeviceException as ex:
198  raise UpdateFailed(ex) from ex
199 
200  return update
201 
202 
203 @dataclass(frozen=True)
205  """A class that holds the vacuum data retrieved by the coordinator."""
206 
207  status: VacuumStatus
208  dnd_status: DNDStatus
209  last_clean_details: CleaningDetails
210  consumable_status: ConsumableStatus
211  clean_history_status: CleaningSummary
212  timers: list[Timer]
213  fan_speeds: dict[str, int]
214  fan_speeds_reverse: dict[int, str]
215 
216 
217 @dataclass(init=False, frozen=True)
219  """A class that holds attribute names for VacuumCoordinatorData.
220 
221  These attributes can be used in methods like `getattr` when a generic solutions is
222  needed.
223  See homeassistant.components.xiaomi_miio.device.XiaomiCoordinatedMiioEntity
224  ._extract_value_from_attribute for
225  an example.
226  """
227 
228  status: str = "status"
229  dnd_status: str = "dnd_status"
230  last_clean_details: str = "last_clean_details"
231  consumable_status: str = "consumable_status"
232  clean_history_status: str = "clean_history_status"
233  timer: str = "timer"
234  fan_speeds: str = "fan_speeds"
235  fan_speeds_reverse: str = "fan_speeds_reverse"
236 
237 
239  hass: HomeAssistant, device: RoborockVacuum
240 ) -> Callable[[], Coroutine[Any, Any, VacuumCoordinatorData]]:
241  def update() -> VacuumCoordinatorData:
242  timer = []
243 
244  # See https://github.com/home-assistant/core/issues/38285 for reason on
245  # Why timers must be fetched separately.
246  try:
247  timer = device.timer()
248  except DeviceException as ex:
249  _LOGGER.debug(
250  "Unable to fetch timers, this may happen on some devices: %s", ex
251  )
252 
253  fan_speeds = device.fan_speed_presets()
254 
255  return VacuumCoordinatorData(
256  device.status(),
257  device.dnd_status(),
258  device.last_clean_details(),
259  device.consumable_status(),
260  device.clean_history(),
261  timer,
262  fan_speeds,
263  {v: k for k, v in fan_speeds.items()},
264  )
265 
266  async def update_async() -> VacuumCoordinatorData:
267  """Fetch data from the device using async_add_executor_job."""
268 
269  async def execute_update() -> VacuumCoordinatorData:
270  async with asyncio.timeout(POLLING_TIMEOUT_SEC):
271  state = await hass.async_add_executor_job(update)
272  _LOGGER.debug("Got new vacuum state: %s", state)
273  return state
274 
275  try:
276  return await execute_update()
277  except DeviceException as ex:
278  if getattr(ex, "code", None) != -9999:
279  raise UpdateFailed(ex) from ex
280  _LOGGER.error(
281  "Got exception while fetching the state, trying again: %s", ex
282  )
283 
284  # Try to fetch the data a second time after error code -9999
285  try:
286  return await execute_update()
287  except DeviceException as ex:
288  raise UpdateFailed(ex) from ex
289 
290  return update_async
291 
292 
294  hass: HomeAssistant, entry: ConfigEntry
295 ) -> None:
296  """Set up a data coordinator and one miio device to service multiple entities."""
297  model: str = entry.data[CONF_MODEL]
298  host = entry.data[CONF_HOST]
299  token = entry.data[CONF_TOKEN]
300  name = entry.title
301  device: MiioDevice | None = None
302  migrate = False
303  update_method = _async_update_data_default
304  coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator
305 
306  # List of models requiring specific lazy_discover setting
307  LAZY_DISCOVER_FOR_MODEL = {
308  "zhimi.fan.za3": True,
309  "zhimi.fan.za5": True,
310  "zhimi.airpurifier.za1": True,
311  "dmaker.fan.1c": True,
312  }
313  lazy_discover = LAZY_DISCOVER_FOR_MODEL.get(model, False)
314 
315  if (
316  model not in MODELS_HUMIDIFIER
317  and model not in MODELS_FAN
318  and model not in MODELS_VACUUM
319  and not model.startswith(ROBOROCK_GENERIC)
320  and not model.startswith(ROCKROBO_GENERIC)
321  ):
322  return
323 
324  _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5])
325 
326  # Humidifiers
327  if model in MODELS_HUMIDIFIER_MIOT:
328  device = AirHumidifierMiot(host, token, lazy_discover=lazy_discover)
329  migrate = True
330  elif model in MODELS_HUMIDIFIER_MJJSQ:
331  device = AirHumidifierMjjsq(
332  host, token, lazy_discover=lazy_discover, model=model
333  )
334  migrate = True
335  elif model in MODELS_HUMIDIFIER_MIIO:
336  device = AirHumidifier(host, token, lazy_discover=lazy_discover, model=model)
337  migrate = True
338  # Airpurifiers and Airfresh
339  elif model in MODELS_PURIFIER_MIOT:
340  device = AirPurifierMiot(host, token, lazy_discover=lazy_discover)
341  elif model.startswith("zhimi.airpurifier."):
342  device = AirPurifier(host, token, lazy_discover=lazy_discover)
343  elif model.startswith("zhimi.airfresh."):
344  device = AirFresh(host, token, lazy_discover=lazy_discover)
345  elif model == MODEL_AIRFRESH_A1:
346  device = AirFreshA1(host, token, lazy_discover=lazy_discover)
347  elif model == MODEL_AIRFRESH_T2017:
348  device = AirFreshT2017(host, token, lazy_discover=lazy_discover)
349  elif model in MODELS_VACUUM or model.startswith(
350  (ROBOROCK_GENERIC, ROCKROBO_GENERIC)
351  ):
352  # TODO: add lazy_discover as argument when python-miio add support # pylint: disable=fixme
353  device = RoborockVacuum(host, token)
354  update_method = _async_update_data_vacuum
355  coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData]
356  # Pedestal fans
357  elif model in MODEL_TO_CLASS_MAP:
358  device = MODEL_TO_CLASS_MAP[model](host, token, lazy_discover=lazy_discover)
359  elif model in MODELS_FAN_MIIO:
360  device = Fan(host, token, lazy_discover=lazy_discover, model=model)
361  else:
362  _LOGGER.error(
363  (
364  "Unsupported device found! Please create an issue at "
365  "https://github.com/syssi/xiaomi_airpurifier/issues "
366  "and provide the following data: %s"
367  ),
368  model,
369  )
370  return
371 
372  if migrate:
373  # Removing fan platform entity for humidifiers and migrate the name
374  # to the config entry for migration
375  entity_registry = er.async_get(hass)
376  assert entry.unique_id
377  entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id)
378  if entity_id:
379  # This check is entities that have a platform migration only
380  # and should be removed in the future
381  if (entity := entity_registry.async_get(entity_id)) and (
382  migrate_entity_name := entity.name
383  ):
384  hass.config_entries.async_update_entry(entry, title=migrate_entity_name)
385  entity_registry.async_remove(entity_id)
386 
387  # Create update miio device and coordinator
388  coordinator = coordinator_class(
389  hass,
390  _LOGGER,
391  config_entry=entry,
392  name=name,
393  update_method=update_method(hass, device),
394  # Polling interval. Will only be polled if there are subscribers.
395  update_interval=UPDATE_INTERVAL,
396  )
397  hass.data[DOMAIN][entry.entry_id] = {
398  KEY_DEVICE: device,
399  KEY_COORDINATOR: coordinator,
400  }
401 
402  # Trigger first data fetch
403  await coordinator.async_config_entry_first_refresh()
404 
405 
406 async def async_setup_gateway_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
407  """Set up the Xiaomi Gateway component from a config entry."""
408  host = entry.data[CONF_HOST]
409  token = entry.data[CONF_TOKEN]
410  name = entry.title
411  gateway_id = entry.unique_id
412 
413  assert gateway_id
414 
415  # Connect to gateway
416  gateway = ConnectXiaomiGateway(hass, entry)
417  try:
418  await gateway.async_connect_gateway(host, token)
419  except AuthException as error:
420  raise ConfigEntryAuthFailed from error
421  except SetupException as error:
422  raise ConfigEntryNotReady from error
423  gateway_info = gateway.gateway_info
424 
425  device_registry = dr.async_get(hass)
426  device_registry.async_get_or_create(
427  config_entry_id=entry.entry_id,
428  connections={(dr.CONNECTION_NETWORK_MAC, gateway_info.mac_address)},
429  identifiers={(DOMAIN, gateway_id)},
430  manufacturer="Xiaomi",
431  name=name,
432  model=gateway_info.model,
433  sw_version=gateway_info.firmware_version,
434  hw_version=gateway_info.hardware_version,
435  )
436 
437  def update_data_factory(sub_device):
438  """Create update function for a subdevice."""
439 
440  async def async_update_data():
441  """Fetch data from the subdevice."""
442  try:
443  await hass.async_add_executor_job(sub_device.update)
444  except GatewayException as ex:
445  _LOGGER.error("Got exception while fetching the state: %s", ex)
446  return {ATTR_AVAILABLE: False}
447  return {ATTR_AVAILABLE: True}
448 
449  return async_update_data
450 
451  coordinator_dict: dict[str, DataUpdateCoordinator] = {}
452  for sub_device in gateway.gateway_device.devices.values():
453  # Create update coordinator
454  coordinator_dict[sub_device.sid] = DataUpdateCoordinator(
455  hass,
456  _LOGGER,
457  config_entry=entry,
458  name=name,
459  update_method=update_data_factory(sub_device),
460  # Polling interval. Will only be polled if there are subscribers.
461  update_interval=UPDATE_INTERVAL,
462  )
463 
464  hass.data[DOMAIN][entry.entry_id] = {
465  CONF_GATEWAY: gateway.gateway_device,
466  KEY_COORDINATOR: coordinator_dict,
467  }
468 
469  await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS)
470 
471  entry.async_on_unload(entry.add_update_listener(update_listener))
472 
473 
474 async def async_setup_device_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
475  """Set up the Xiaomi Miio device component from a config entry."""
476  platforms = get_platforms(entry)
478 
479  if not platforms:
480  return False
481 
482  await hass.config_entries.async_forward_entry_setups(entry, platforms)
483 
484  entry.async_on_unload(entry.add_update_listener(update_listener))
485 
486  return True
487 
488 
489 async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
490  """Unload a config entry."""
491  platforms = get_platforms(config_entry)
492 
493  unload_ok = await hass.config_entries.async_unload_platforms(
494  config_entry, platforms
495  )
496 
497  if unload_ok:
498  hass.data[DOMAIN].pop(config_entry.entry_id)
499 
500  return unload_ok
501 
502 
503 async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
504  """Handle options update."""
505  await hass.config_entries.async_reload(config_entry.entry_id)
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
def _async_update_data_default(hass, device)
Definition: __init__.py:175
Callable[[], Coroutine[Any, Any, VacuumCoordinatorData]] _async_update_data_vacuum(HomeAssistant hass, RoborockVacuum device)
Definition: __init__.py:240
bool async_setup_device_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:474
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:128
None update_listener(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:503
None async_setup_gateway_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:406
bool async_unload_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:489
None async_create_miio_device_and_coordinator(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:295