diff --git a/.coveragerc b/.coveragerc index 8a53086d840..8ee3a3ceab6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1234,7 +1234,6 @@ omit = homeassistant/components/xiaomi_miio/select.py homeassistant/components/xiaomi_miio/sensor.py homeassistant/components/xiaomi_miio/switch.py - homeassistant/components/xiaomi_miio/vacuum.py homeassistant/components/xiaomi_tv/media_player.py homeassistant/components/xmpp/notify.py homeassistant/components/xs1/* diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 157199e977a..11fe4cc1337 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -1,4 +1,7 @@ """Support for Xiaomi Miio.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging @@ -11,7 +14,11 @@ from miio import ( AirPurifier, AirPurifierMB4, AirPurifierMiot, + CleaningDetails, + CleaningSummary, + ConsumableStatus, DeviceException, + DNDStatus, Fan, Fan1C, FanP5, @@ -19,6 +26,9 @@ from miio import ( FanP10, FanP11, FanZA5, + Timer, + Vacuum, + VacuumStatus, ) from miio.gateway.gateway import GatewayException @@ -72,7 +82,7 @@ HUMIDIFIER_PLATFORMS = [ "switch", ] LIGHT_PLATFORMS = ["light"] -VACUUM_PLATFORMS = ["vacuum"] +VACUUM_PLATFORMS = ["binary_sensor", "sensor", "vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] MODEL_TO_CLASS_MAP = { @@ -133,6 +143,99 @@ def get_platforms(config_entry): return [] +def _async_update_data_default(hass, device): + async def update(): + """Fetch data from the device using async_add_executor_job.""" + try: + async with async_timeout.timeout(10): + state = await hass.async_add_executor_job(device.status) + _LOGGER.debug("Got new state: %s", state) + return state + + except DeviceException as ex: + raise UpdateFailed(ex) from ex + + return update + + +@dataclass(frozen=True) +class VacuumCoordinatorData: + """A class that holds the vacuum data retrieved by the coordinator.""" + + status: VacuumStatus + dnd_status: DNDStatus + last_clean_details: CleaningDetails + consumable_status: ConsumableStatus + clean_history_status: CleaningSummary + timers: list[Timer] + fan_speeds: dict[str, int] + fan_speeds_reverse: dict[int, str] + + +@dataclass(init=False, frozen=True) +class VacuumCoordinatorDataAttributes: + """ + A class that holds attribute names for VacuumCoordinatorData. + + These attributes can be used in methods like `getattr` when a generic solutions is + needed. + See homeassistant.components.xiaomi_miio.device.XiaomiCoordinatedMiioEntity + ._extract_value_from_attribute for + an example. + """ + + status: str = "status" + dnd_status: str = "dnd_status" + last_clean_details: str = "last_clean_details" + consumable_status: str = "consumable_status" + clean_history_status: str = "clean_history_status" + timer: str = "timer" + fan_speeds: str = "fan_speeds" + fan_speeds_reverse: str = "fan_speeds_reverse" + + +def _async_update_data_vacuum(hass, device: Vacuum): + def update() -> VacuumCoordinatorData: + timer = [] + + # See https://github.com/home-assistant/core/issues/38285 for reason on + # Why timers must be fetched separately. + try: + timer = device.timer() + except DeviceException as ex: + _LOGGER.debug( + "Unable to fetch timers, this may happen on some devices: %s", ex + ) + + fan_speeds = device.fan_speed_presets() + + data = VacuumCoordinatorData( + device.status(), + device.dnd_status(), + device.last_clean_details(), + device.consumable_status(), + device.clean_history(), + timer, + fan_speeds, + {v: k for k, v in fan_speeds.items()}, + ) + + return data + + async def update_async(): + """Fetch data from the device using async_add_executor_job.""" + try: + async with async_timeout.timeout(10): + state = await hass.async_add_executor_job(update) + _LOGGER.debug("Got new vacuum state: %s", state) + return state + + except DeviceException as ex: + raise UpdateFailed(ex) from ex + + return update_async + + async def async_create_miio_device_and_coordinator( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): @@ -143,8 +246,14 @@ async def async_create_miio_device_and_coordinator( name = entry.title device = None migrate = False + update_method = _async_update_data_default + coordinator_class = DataUpdateCoordinator - if model not in MODELS_HUMIDIFIER and model not in MODELS_FAN: + if ( + model not in MODELS_HUMIDIFIER + and model not in MODELS_FAN + and model not in MODELS_VACUUM + ): return _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) @@ -168,6 +277,10 @@ async def async_create_miio_device_and_coordinator( device = AirPurifier(host, token) elif model.startswith("zhimi.airfresh."): device = AirFresh(host, token) + elif model in MODELS_VACUUM: + device = Vacuum(host, token) + update_method = _async_update_data_vacuum + coordinator_class = DataUpdateCoordinator[VacuumCoordinatorData] # Pedestal fans elif model in MODEL_TO_CLASS_MAP: device = MODEL_TO_CLASS_MAP[model](host, token) @@ -192,34 +305,12 @@ async def async_create_miio_device_and_coordinator( hass.config_entries.async_update_entry(entry, title=migrate_entity_name) entity_registry.async_remove(entity_id) - async def async_update_data(): - """Fetch data from the device using async_add_executor_job.""" - - async def _async_fetch_data(): - """Fetch data from the device.""" - async with async_timeout.timeout(10): - state = await hass.async_add_executor_job(device.status) - _LOGGER.debug("Got new state: %s", state) - return state - - try: - return await _async_fetch_data() - except DeviceException as ex: - if getattr(ex, "code", None) != -9999: - raise UpdateFailed(ex) from ex - _LOGGER.info("Got exception while fetching the state, trying again: %s", ex) - # Try to fetch the data a second time after error code -9999 - try: - return await _async_fetch_data() - except DeviceException as ex: - raise UpdateFailed(ex) from ex - # Create update miio device and coordinator - coordinator = DataUpdateCoordinator( + coordinator = coordinator_class( hass, _LOGGER, name=name, - update_method=async_update_data, + update_method=update_method(hass, device), # Polling interval. Will only be polled if there are subscribers. update_interval=timedelta(seconds=60), ) diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 61c3a4fde61..28553c159fe 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -1,17 +1,19 @@ """Support for Xiaomi Miio binary sensors.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass -from enum import Enum +import logging +from typing import Callable from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_PLUG, + DEVICE_CLASS_PROBLEM, BinarySensorEntity, BinarySensorEntityDescription, ) +from . import VacuumCoordinatorDataAttributes from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, @@ -23,12 +25,20 @@ from .const import ( MODELS_HUMIDIFIER_MIIO, MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, + MODELS_VACUUM, + MODELS_VACUUM_WITH_MOP, ) from .device import XiaomiCoordinatedMiioEntity +_LOGGER = logging.getLogger(__name__) + + ATTR_NO_WATER = "no_water" ATTR_POWERSUPPLY_ATTACHED = "powersupply_attached" ATTR_WATER_TANK_DETACHED = "water_tank_detached" +ATTR_MOP_ATTACHED = "is_water_box_carriage_attached" +ATTR_WATER_BOX_ATTACHED = "is_water_box_attached" +ATTR_WATER_SHORTAGE = "is_water_shortage" @dataclass @@ -36,6 +46,7 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): """A class that describes binary sensor entities.""" value: Callable | None = None + parent_key: str | None = None BINARY_SENSOR_TYPES = ( @@ -59,11 +70,63 @@ BINARY_SENSOR_TYPES = ( ) FAN_ZA5_BINARY_SENSORS = (ATTR_POWERSUPPLY_ATTACHED,) + +VACUUM_SENSORS = { + ATTR_MOP_ATTACHED: XiaomiMiioBinarySensorDescription( + key=ATTR_MOP_ATTACHED, + name="Mop Attached", + icon="mdi:square-rounded", + parent_key=VacuumCoordinatorDataAttributes.status, + entity_registry_enabled_default=True, + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + ATTR_WATER_BOX_ATTACHED: XiaomiMiioBinarySensorDescription( + key=ATTR_WATER_BOX_ATTACHED, + name="Water Box Attached", + icon="mdi:water", + parent_key=VacuumCoordinatorDataAttributes.status, + entity_registry_enabled_default=True, + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + ATTR_WATER_SHORTAGE: XiaomiMiioBinarySensorDescription( + key=ATTR_WATER_SHORTAGE, + name="Water Shortage", + icon="mdi:water", + parent_key=VacuumCoordinatorDataAttributes.status, + entity_registry_enabled_default=True, + device_class=DEVICE_CLASS_PROBLEM, + ), +} + HUMIDIFIER_MIIO_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MIOT_BINARY_SENSORS = (ATTR_WATER_TANK_DETACHED,) HUMIDIFIER_MJJSQ_BINARY_SENSORS = (ATTR_NO_WATER, ATTR_WATER_TANK_DETACHED) +def _setup_vacuum_sensors(hass, config_entry, async_add_entities): + """Only vacuums with mop should have binary sensor registered.""" + + if config_entry.data[CONF_MODEL] not in MODELS_VACUUM_WITH_MOP: + return + + device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) + entities = [] + + for sensor, description in VACUUM_SENSORS.items(): + entities.append( + XiaomiGenericBinarySensor( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{sensor}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) + ) + + async_add_entities(entities) + + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi sensor from a config entry.""" entities = [] @@ -79,6 +142,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = HUMIDIFIER_MIOT_BINARY_SENSORS elif model in MODELS_HUMIDIFIER_MJJSQ: sensors = HUMIDIFIER_MJJSQ_BINARY_SENSORS + elif model in MODELS_VACUUM: + return _setup_vacuum_sensors(hass, config_entry, async_add_entities) + for description in BINARY_SENSOR_TYPES: if description.key not in sensors: continue @@ -103,11 +169,20 @@ class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity) """Initialize the entity.""" super().__init__(name, device, entry, unique_id, coordinator) - self.entity_description = description + self.entity_description: XiaomiMiioBinarySensorDescription = description + self._attr_entity_registry_enabled_default = ( + description.entity_registry_enabled_default + ) @property def is_on(self): """Return true if the binary sensor is on.""" + if self.entity_description.parent_key is not None: + return self._extract_value_from_attribute( + getattr(self.coordinator.data, self.entity_description.parent_key), + self.entity_description.key, + ) + state = self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key ) @@ -115,11 +190,3 @@ class XiaomiGenericBinarySensor(XiaomiCoordinatedMiioEntity, BinarySensorEntity) return self.entity_description.value(state) return state - - @staticmethod - def _extract_value_from_attribute(state, attribute): - value = getattr(state, attribute) - if isinstance(value, Enum): - return value.value - - return value diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index cda65bdf0aa..4adc2d287dd 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -1,4 +1,12 @@ """Constants for the Xiaomi Miio component.""" +from miio.vacuum import ( + ROCKROBO_S5, + ROCKROBO_S6, + ROCKROBO_S6_MAXV, + ROCKROBO_S7, + ROCKROBO_V1, +) + DOMAIN = "xiaomi_miio" # Config flow @@ -177,7 +185,8 @@ MODELS_LIGHT = ( + MODELS_LIGHT_BULB + MODELS_LIGHT_MONO ) -MODELS_VACUUM = ["roborock.vacuum", "rockrobo.vacuum"] +MODELS_VACUUM = [ROCKROBO_V1, ROCKROBO_S5, ROCKROBO_S6, ROCKROBO_S6_MAXV, ROCKROBO_S7] +MODELS_VACUUM_WITH_MOP = [ROCKROBO_S5, ROCKROBO_S6, ROCKROBO_S6_MAXV, ROCKROBO_S7] MODELS_AIR_MONITOR = [ MODEL_AIRQUALITYMONITOR_V1, MODEL_AIRQUALITYMONITOR_B1, diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index f8402138f21..aa81d8c23b6 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -1,4 +1,6 @@ """Code to handle a Xiaomi Device.""" +import datetime +from enum import Enum from functools import partial import logging @@ -157,3 +159,53 @@ class XiaomiCoordinatedMiioEntity(CoordinatorEntity): _LOGGER.error(mask_error, exc) return False + + @classmethod + def _extract_value_from_attribute(cls, state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + if isinstance(value, datetime.timedelta): + return cls._parse_time_delta(value) + if isinstance(value, datetime.time): + return cls._parse_datetime_time(value) + if isinstance(value, datetime.datetime): + return cls._parse_datetime_datetime(value) + if isinstance(value, datetime.timedelta): + return cls._parse_time_delta(value) + if isinstance(value, float): + return value + if isinstance(value, int): + return value + + _LOGGER.warning( + "Could not determine how to parse state value of type %s for state %s and attribute %s", + type(value), + type(state), + attribute, + ) + + return value + + @staticmethod + def _parse_time_delta(timedelta: datetime.timedelta) -> int: + return timedelta.seconds + + @staticmethod + def _parse_datetime_time(time: datetime.time) -> str: + time = datetime.datetime.now().replace( + hour=time.hour, minute=time.minute, second=0, microsecond=0 + ) + + if time < datetime.datetime.now(): + time += datetime.timedelta(days=1) + + return time.isoformat() + + @staticmethod + def _parse_datetime_datetime(time: datetime.datetime) -> str: + return time.isoformat() + + @staticmethod + def _parse_datetime_timedelta(time: datetime.timedelta) -> int: + return time.seconds diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index d199c051eae..e7516bbca65 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from enum import Enum import logging from miio import AirQualityMonitor, DeviceException @@ -22,6 +21,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import ( + AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -37,15 +37,18 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, TIME_HOURS, + TIME_SECONDS, VOLUME_CUBIC_METERS, ) +from . import VacuumCoordinatorDataAttributes from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, @@ -75,6 +78,7 @@ from .const import ( MODELS_HUMIDIFIER_MJJSQ, MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, + MODELS_VACUUM, ) from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice @@ -109,6 +113,20 @@ ATTR_PRESSURE = "pressure" ATTR_PURIFY_VOLUME = "purify_volume" ATTR_SENSOR_STATE = "sensor_state" ATTR_WATER_LEVEL = "water_level" +ATTR_DND_START = "start" +ATTR_DND_END = "end" +ATTR_LAST_CLEAN_TIME = "duration" +ATTR_LAST_CLEAN_AREA = "area" +ATTR_LAST_CLEAN_START = "start" +ATTR_LAST_CLEAN_END = "end" +ATTR_CLEAN_HISTORY_TOTAL_DURATION = "total_duration" +ATTR_CLEAN_HISTORY_TOTAL_AREA = "total_area" +ATTR_CLEAN_HISTORY_COUNT = "count" +ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT = "dust_collection_count" +ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT = "main_brush_left" +ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT = "side_brush_left" +ATTR_CONSUMABLE_STATUS_FILTER_LEFT = "filter_left" +ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT = "sensor_dirty_left" @dataclass @@ -116,6 +134,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" attributes: tuple = () + parent_key: str | None = None SENSOR_TYPES = { @@ -348,6 +367,138 @@ MODEL_TO_SENSORS_MAP = { MODEL_FAN_ZA5: FAN_ZA5_SENSORS, } +VACUUM_SENSORS = { + f"dnd_{ATTR_DND_START}": XiaomiMiioSensorDescription( + key=ATTR_DND_START, + icon="mdi:minus-circle-off", + name="DnD Start", + device_class=DEVICE_CLASS_TIMESTAMP, + parent_key=VacuumCoordinatorDataAttributes.dnd_status, + entity_registry_enabled_default=False, + ), + f"dnd_{ATTR_DND_END}": XiaomiMiioSensorDescription( + key=ATTR_DND_END, + icon="mdi:minus-circle-off", + name="DnD End", + device_class=DEVICE_CLASS_TIMESTAMP, + parent_key=VacuumCoordinatorDataAttributes.dnd_status, + entity_registry_enabled_default=False, + ), + f"last_clean_{ATTR_LAST_CLEAN_START}": XiaomiMiioSensorDescription( + key=ATTR_LAST_CLEAN_START, + icon="mdi:clock-time-twelve", + name="Last Clean Start", + device_class=DEVICE_CLASS_TIMESTAMP, + parent_key=VacuumCoordinatorDataAttributes.last_clean_details, + ), + f"last_clean_{ATTR_LAST_CLEAN_END}": XiaomiMiioSensorDescription( + key=ATTR_LAST_CLEAN_END, + icon="mdi:clock-time-twelve", + device_class=DEVICE_CLASS_TIMESTAMP, + parent_key=VacuumCoordinatorDataAttributes.last_clean_details, + name="Last Clean End", + ), + f"last_clean_{ATTR_LAST_CLEAN_TIME}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-sand", + key=ATTR_LAST_CLEAN_TIME, + parent_key=VacuumCoordinatorDataAttributes.last_clean_details, + name="Last Clean Duration", + ), + f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( + native_unit_of_measurement=AREA_SQUARE_METERS, + icon="mdi:texture-box", + key=ATTR_LAST_CLEAN_AREA, + parent_key=VacuumCoordinatorDataAttributes.last_clean_details, + name="Last Clean Area", + ), + f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_DURATION}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:timer-sand", + key=ATTR_CLEAN_HISTORY_TOTAL_DURATION, + parent_key=VacuumCoordinatorDataAttributes.clean_history_status, + name="Total duration", + entity_registry_enabled_default=False, + ), + f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_AREA}": XiaomiMiioSensorDescription( + native_unit_of_measurement=AREA_SQUARE_METERS, + icon="mdi:texture-box", + key=ATTR_CLEAN_HISTORY_TOTAL_AREA, + parent_key=VacuumCoordinatorDataAttributes.clean_history_status, + name="Total Clean Area", + entity_registry_enabled_default=False, + ), + f"clean_history_{ATTR_CLEAN_HISTORY_COUNT}": XiaomiMiioSensorDescription( + native_unit_of_measurement="", + icon="mdi:counter", + state_class=STATE_CLASS_TOTAL_INCREASING, + key=ATTR_CLEAN_HISTORY_COUNT, + parent_key=VacuumCoordinatorDataAttributes.clean_history_status, + name="Total Clean Count", + entity_registry_enabled_default=False, + ), + f"clean_history_{ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT}": XiaomiMiioSensorDescription( + native_unit_of_measurement="", + icon="mdi:counter", + state_class="total_increasing", + key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, + parent_key=VacuumCoordinatorDataAttributes.clean_history_status, + name="Total Dust Collection Count", + entity_registry_enabled_default=False, + ), + f"consumable_{ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:brush", + key=ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT, + parent_key=VacuumCoordinatorDataAttributes.consumable_status, + name="Main Brush Left", + entity_registry_enabled_default=False, + ), + f"consumable_{ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:brush", + key=ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT, + parent_key=VacuumCoordinatorDataAttributes.consumable_status, + name="Side Brush Left", + entity_registry_enabled_default=False, + ), + f"consumable_{ATTR_CONSUMABLE_STATUS_FILTER_LEFT}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:air-filter", + key=ATTR_CONSUMABLE_STATUS_FILTER_LEFT, + parent_key=VacuumCoordinatorDataAttributes.consumable_status, + name="Filter Left", + entity_registry_enabled_default=False, + ), + f"consumable_{ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT}": XiaomiMiioSensorDescription( + native_unit_of_measurement=TIME_SECONDS, + icon="mdi:eye-outline", + key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, + parent_key=VacuumCoordinatorDataAttributes.consumable_status, + name="Sensor Dirty Left", + entity_registry_enabled_default=False, + ), +} + + +def _setup_vacuum_sensors(hass, config_entry, async_add_entities): + device = hass.data[DOMAIN][config_entry.entry_id].get(KEY_DEVICE) + entities = [] + + for sensor, description in VACUUM_SENSORS.items(): + entities.append( + XiaomiGenericSensor( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{sensor}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) + ) + + async_add_entities(entities) + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Xiaomi sensor from a config entry.""" @@ -416,6 +567,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = PURIFIER_MIIO_SENSORS elif model in MODELS_PURIFIER_MIOT: sensors = PURIFIER_MIOT_SENSORS + elif model in MODELS_VACUUM: + return _setup_vacuum_sensors(hass, config_entry, async_add_entities) for sensor, description in SENSOR_TYPES.items(): if sensor not in sensors: @@ -435,19 +588,31 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): - """Representation of a Xiaomi Humidifier sensor.""" + """Representation of a Xiaomi generic sensor.""" - def __init__(self, name, device, entry, unique_id, coordinator, description): + def __init__( + self, + name, + device, + entry, + unique_id, + coordinator, + description: XiaomiMiioSensorDescription, + ): """Initialize the entity.""" super().__init__(name, device, entry, unique_id, coordinator) - - self._attr_name = name self._attr_unique_id = unique_id - self.entity_description = description + self.entity_description: XiaomiMiioSensorDescription = description @property def native_value(self): """Return the state of the device.""" + if self.entity_description.parent_key is not None: + return self._extract_value_from_attribute( + getattr(self.coordinator.data, self.entity_description.parent_key), + self.entity_description.key, + ) + return self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key ) @@ -461,14 +626,6 @@ class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): if hasattr(self.coordinator.data, attr) } - @staticmethod - def _extract_value_from_attribute(state, attribute): - value = getattr(state, attribute) - if isinstance(value, Enum): - return value.value - - return value - class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): """Representation of a Xiaomi Air Quality Monitor.""" diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 94d93d77f2f..60d557837fb 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -1,12 +1,13 @@ """Support for the Xiaomi vacuum cleaner robot.""" +from __future__ import annotations + from functools import partial import logging -from miio import DeviceException, Vacuum +from miio import DeviceException import voluptuous as vol from homeassistant.components.vacuum import ( - ATTR_CLEANED_AREA, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, @@ -25,13 +26,18 @@ from homeassistant.components.vacuum import ( SUPPORT_STOP, StateVacuumEntity, ) -from homeassistant.const import CONF_HOST, CONF_TOKEN, STATE_OFF, STATE_ON +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.util.dt import as_utc +from . import VacuumCoordinatorData +from ...helpers.update_coordinator import DataUpdateCoordinator from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -40,25 +46,10 @@ from .const import ( SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL, ) -from .device import XiaomiMiioEntity +from .device import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Xiaomi Vacuum cleaner" - -ATTR_CLEAN_START = "clean_start" -ATTR_CLEAN_STOP = "clean_stop" -ATTR_CLEANING_TIME = "cleaning_time" -ATTR_DO_NOT_DISTURB = "do_not_disturb" -ATTR_DO_NOT_DISTURB_START = "do_not_disturb_start" -ATTR_DO_NOT_DISTURB_END = "do_not_disturb_end" -ATTR_MAIN_BRUSH_LEFT = "main_brush_left" -ATTR_SIDE_BRUSH_LEFT = "side_brush_left" -ATTR_FILTER_LEFT = "filter_left" -ATTR_SENSOR_DIRTY_LEFT = "sensor_dirty_left" -ATTR_CLEANING_COUNT = "cleaning_count" -ATTR_CLEANED_TOTAL_AREA = "total_cleaned_area" -ATTR_CLEANING_TOTAL_TIME = "total_cleaning_time" ATTR_ERROR = "error" ATTR_RC_DURATION = "duration" ATTR_RC_ROTATION = "rotation" @@ -67,7 +58,6 @@ ATTR_STATUS = "status" ATTR_ZONE_ARRAY = "zone" ATTR_ZONE_REPEATER = "repeats" ATTR_TIMERS = "timers" -ATTR_MOP_ATTACHED = "mop_attached" SUPPORT_XIAOMI = ( SUPPORT_STATE @@ -112,16 +102,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: - host = config_entry.data[CONF_HOST] - token = config_entry.data[CONF_TOKEN] name = config_entry.title unique_id = config_entry.unique_id - # Create handler - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) - vacuum = Vacuum(host, token) - - mirobo = MiroboVacuum(name, vacuum, config_entry, unique_id) + mirobo = MiroboVacuum( + name, + hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE], + config_entry, + unique_id, + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + ) entities.append(mirobo) platform = entity_platform.async_get_current_platform() @@ -206,65 +196,57 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(entities, update_before_add=True) -class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): +class MiroboVacuum(XiaomiCoordinatedMiioEntity, StateVacuumEntity): """Representation of a Xiaomi Vacuum cleaner robot.""" - def __init__(self, name, device, entry, unique_id): + coordinator: DataUpdateCoordinator[VacuumCoordinatorData] + + def __init__( + self, name, device, entry, unique_id, coordinator: DataUpdateCoordinator + ): """Initialize the Xiaomi vacuum cleaner robot handler.""" - super().__init__(name, device, entry, unique_id) + super().__init__(name, device, entry, unique_id, coordinator) + self._state: str | None = None - self.vacuum_state = None - self._available = False - - self.consumable_state = None - self.clean_history = None - self.dnd_state = None - self.last_clean = None - self._fan_speeds = None - self._fan_speeds_reverse = None - - self._timers = None + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() @property def state(self): """Return the status of the vacuum cleaner.""" - if self.vacuum_state is not None: - # The vacuum reverts back to an idle state after erroring out. - # We want to keep returning an error until it has been cleared. - if self.vacuum_state.got_error: - return STATE_ERROR - try: - return STATE_CODE_TO_STATE[int(self.vacuum_state.state_code)] - except KeyError: - _LOGGER.error( - "STATE not supported: %s, state_code: %s", - self.vacuum_state.state, - self.vacuum_state.state_code, - ) - return None + # The vacuum reverts back to an idle state after erroring out. + # We want to keep returning an error until it has been cleared. + if self.coordinator.data.status.got_error: + return STATE_ERROR + + return self._state @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" - if self.vacuum_state is not None: - return self.vacuum_state.battery + return self.coordinator.data.status.battery @property def fan_speed(self): """Return the fan speed of the vacuum cleaner.""" - if self.vacuum_state is not None: - speed = self.vacuum_state.fanspeed - if speed in self._fan_speeds_reverse: - return self._fan_speeds_reverse[speed] + speed = self.coordinator.data.status.fanspeed + if speed in self.coordinator.data.fan_speeds_reverse: + return self.coordinator.data.fan_speeds_reverse[speed] - _LOGGER.debug("Unable to find reverse for %s", speed) + _LOGGER.debug("Unable to find reverse for %s", speed) - return speed + return speed @property def fan_speed_list(self): """Get the list of available fan speed steps of the vacuum cleaner.""" - return list(self._fan_speeds) if self._fan_speeds else [] + return ( + list(self.coordinator.data.fan_speeds) + if self.coordinator.data.fan_speeds + else [] + ) @property def timers(self): @@ -275,65 +257,22 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): "cron": timer.cron, "next_schedule": as_utc(timer.next_schedule), } - for timer in self._timers + for timer in self.coordinator.data.timers ] @property def extra_state_attributes(self): """Return the specific state attributes of this vacuum cleaner.""" attrs = {} - if self.vacuum_state is not None: - attrs.update( - { - ATTR_DO_NOT_DISTURB: STATE_ON - if self.dnd_state.enabled - else STATE_OFF, - ATTR_DO_NOT_DISTURB_START: str(self.dnd_state.start), - ATTR_DO_NOT_DISTURB_END: str(self.dnd_state.end), - # Not working --> 'Cleaning mode': - # STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF, - ATTR_CLEANING_TIME: int( - self.vacuum_state.clean_time.total_seconds() / 60 - ), - ATTR_CLEANED_AREA: int(self.vacuum_state.clean_area), - ATTR_CLEANING_COUNT: int(self.clean_history.count), - ATTR_CLEANED_TOTAL_AREA: int(self.clean_history.total_area), - ATTR_CLEANING_TOTAL_TIME: int( - self.clean_history.total_duration.total_seconds() / 60 - ), - ATTR_MAIN_BRUSH_LEFT: int( - self.consumable_state.main_brush_left.total_seconds() / 3600 - ), - ATTR_SIDE_BRUSH_LEFT: int( - self.consumable_state.side_brush_left.total_seconds() / 3600 - ), - ATTR_FILTER_LEFT: int( - self.consumable_state.filter_left.total_seconds() / 3600 - ), - ATTR_SENSOR_DIRTY_LEFT: int( - self.consumable_state.sensor_dirty_left.total_seconds() / 3600 - ), - ATTR_STATUS: str(self.vacuum_state.state), - ATTR_MOP_ATTACHED: self.vacuum_state.is_water_box_attached, - } - ) + attrs[ATTR_STATUS] = str(self.coordinator.data.status.state) - if self.last_clean: - attrs[ATTR_CLEAN_START] = self.last_clean.start - attrs[ATTR_CLEAN_STOP] = self.last_clean.end + if self.coordinator.data.status.got_error: + attrs[ATTR_ERROR] = self.coordinator.data.status.error - if self.vacuum_state.got_error: - attrs[ATTR_ERROR] = self.vacuum_state.error - - if self.timers: - attrs[ATTR_TIMERS] = self.timers + if self.timers: + attrs[ATTR_TIMERS] = self.timers return attrs - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - @property def supported_features(self): """Flag vacuum cleaner robot features that are supported.""" @@ -343,6 +282,7 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): """Call a vacuum command handling error messages.""" try: await self.hass.async_add_executor_job(partial(func, *args, **kwargs)) + await self.coordinator.async_refresh() return True except DeviceException as exc: _LOGGER.error(mask_error, exc) @@ -364,8 +304,8 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" - if fan_speed in self._fan_speeds: - fan_speed = self._fan_speeds[fan_speed] + if fan_speed in self.coordinator.data.fan_speeds: + fan_speed = self.coordinator.data.fan_speeds[fan_speed] else: try: fan_speed = int(fan_speed) @@ -459,39 +399,6 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): segments=segments, ) - def update(self): - """Fetch state from the device.""" - try: - state = self._device.status() - self.vacuum_state = state - - self._fan_speeds = self._device.fan_speed_presets() - self._fan_speeds_reverse = {v: k for k, v in self._fan_speeds.items()} - - self.consumable_state = self._device.consumable_status() - self.clean_history = self._device.clean_history() - self.last_clean = self._device.last_clean_details() - self.dnd_state = self._device.dnd_status() - - self._available = True - except (OSError, DeviceException) as exc: - if self._available: - self._available = False - _LOGGER.warning("Got exception while fetching the state: %s", exc) - - # Fetch timers separately, see #38285 - try: - # Do not try this if the first fetch timed out. - # Two timeouts take longer than 10 seconds and trigger a warning. - # See #52353 - if self._available: - self._timers = self._device.timer() - except DeviceException as exc: - _LOGGER.debug( - "Unable to fetch timers, this may happen on some devices: %s", exc - ) - self._timers = [] - async def async_clean_zone(self, zone, repeats=1): """Clean selected area for the number of repeats indicated.""" for _zone in zone: @@ -499,5 +406,21 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): _LOGGER.debug("Zone with repeats: %s", zone) try: await self.hass.async_add_executor_job(self._device.zoned_clean, zone) + await self.coordinator.async_refresh() except (OSError, DeviceException) as exc: _LOGGER.error("Unable to send zoned_clean command to the vacuum: %s", exc) + + @callback + def _handle_coordinator_update(self) -> None: + state_code = int(self.coordinator.data.status.state_code) + if state_code not in STATE_CODE_TO_STATE: + _LOGGER.error( + "STATE not supported: %s, state_code: %s", + self.coordinator.data.status.state, + self.coordinator.data.status.state_code, + ) + self._state = None + else: + self._state = STATE_CODE_TO_STATE[state_code] + + super()._handle_coordinator_update() diff --git a/tests/components/xiaomi_miio/__init__.py b/tests/components/xiaomi_miio/__init__.py index 9f162e02f28..24e66e16b08 100644 --- a/tests/components/xiaomi_miio/__init__.py +++ b/tests/components/xiaomi_miio/__init__.py @@ -1 +1,2 @@ """Tests for the Xiaomi Miio integration.""" +TEST_MAC = "ab:cd:ef:gh:ij:kl" diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 454940f1356..5e7c0351c14 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -8,6 +8,8 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components.xiaomi_miio import const from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from . import TEST_MAC + from tests.common import MockConfigEntry ZEROCONF_NAME = "name" @@ -23,7 +25,6 @@ TEST_TOKEN = "12345678901234567890123456789012" TEST_NAME = "Test_Gateway" TEST_NAME2 = "Test_Gateway_2" TEST_MODEL = const.MODELS_GATEWAY[0] -TEST_MAC = "ab:cd:ef:gh:ij:kl" TEST_MAC2 = "mn:op:qr:st:uv:wx" TEST_MAC_DEVICE = "abcdefghijkl" TEST_MAC_DEVICE2 = "mnopqrstuvwx" @@ -31,7 +32,6 @@ TEST_GATEWAY_ID = TEST_MAC TEST_HARDWARE_VERSION = "AB123" TEST_FIRMWARE_VERSION = "1.2.3_456" TEST_ZEROCONF_NAME = "lumi-gateway-v3_miio12345678._miio._udp.local." -TEST_SUB_DEVICE_LIST = [] TEST_CLOUD_DEVICES_1 = [ { "parent_id": None, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 0eb806c0a64..10f1dd649c8 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -13,6 +13,7 @@ from homeassistant.components.vacuum import ( DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, + SERVICE_PAUSE, SERVICE_RETURN_TO_BASE, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, @@ -21,24 +22,17 @@ from homeassistant.components.vacuum import ( STATE_CLEANING, STATE_ERROR, ) -from homeassistant.components.xiaomi_miio import const -from homeassistant.components.xiaomi_miio.const import DOMAIN as XIAOMI_DOMAIN +from homeassistant.components.xiaomi_miio.const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MAC, + CONF_MODEL, + DOMAIN as XIAOMI_DOMAIN, + MODELS_VACUUM, +) from homeassistant.components.xiaomi_miio.vacuum import ( - ATTR_CLEANED_AREA, - ATTR_CLEANED_TOTAL_AREA, - ATTR_CLEANING_COUNT, - ATTR_CLEANING_TIME, - ATTR_CLEANING_TOTAL_TIME, - ATTR_DO_NOT_DISTURB, - ATTR_DO_NOT_DISTURB_END, - ATTR_DO_NOT_DISTURB_START, ATTR_ERROR, - ATTR_FILTER_LEFT, - ATTR_MAIN_BRUSH_LEFT, - ATTR_SIDE_BRUSH_LEFT, ATTR_TIMERS, - CONF_HOST, - CONF_TOKEN, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -50,17 +44,17 @@ from homeassistant.components.xiaomi_miio.vacuum import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - STATE_OFF, - STATE_ON, + CONF_HOST, + CONF_TOKEN, STATE_UNAVAILABLE, ) from homeassistant.util import dt as dt_util -from .test_config_flow import TEST_MAC +from . import TEST_MAC -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -PLATFORM = "xiaomi_miio" +# pylint: disable=consider-using-tuple # calls made when device status is requested STATUS_CALLS = [ @@ -115,7 +109,7 @@ def mirobo_is_got_error_fixture(): mock_vacuum.timer.return_value = [mock_timer_1, mock_timer_2] - with patch("homeassistant.components.xiaomi_miio.vacuum.Vacuum") as mock_vacuum_cls: + with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls: mock_vacuum_cls.return_value = mock_vacuum yield mock_vacuum @@ -143,7 +137,7 @@ def mirobo_old_speeds_fixture(request): mock_vacuum.fan_speed_presets.return_value = request.param mock_vacuum.status().fanspeed = list(request.param.values())[0] - with patch("homeassistant.components.xiaomi_miio.vacuum.Vacuum") as mock_vacuum_cls: + with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls: mock_vacuum_cls.return_value = mock_vacuum yield mock_vacuum @@ -154,7 +148,8 @@ def mirobo_is_on_fixture(): mock_vacuum = MagicMock() mock_vacuum.status().data = {"test": "raw"} mock_vacuum.status().is_on = True - mock_vacuum.status().fanspeed = 99 + mock_vacuum.fan_speed_presets.return_value = new_fanspeeds + mock_vacuum.status().fanspeed = list(new_fanspeeds.values())[0] mock_vacuum.status().got_error = False mock_vacuum.status().battery = 32 mock_vacuum.status().clean_area = 133.43218 @@ -176,6 +171,19 @@ def mirobo_is_on_fixture(): mock_vacuum.status().state = "Test Xiaomi Cleaning" mock_vacuum.status().state_code = 5 mock_vacuum.dnd_status().enabled = False + mock_vacuum.last_clean_details().start = datetime( + 2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC + ) + mock_vacuum.last_clean_details().end = datetime( + 2020, 4, 1, 13, 21, 10, tzinfo=dt_util.UTC + ) + mock_vacuum.last_clean_details().duration = timedelta( + hours=11, minutes=15, seconds=34 + ) + mock_vacuum.last_clean_details().area = 133.43218 + mock_vacuum.last_clean_details().error_code = 1 + mock_vacuum.last_clean_details().error = "test_error_code" + mock_vacuum.last_clean_details().complete = True mock_timer_1 = MagicMock() mock_timer_1.enabled = True @@ -189,12 +197,12 @@ def mirobo_is_on_fixture(): mock_vacuum.timer.return_value = [mock_timer_1, mock_timer_2] - with patch("homeassistant.components.xiaomi_miio.vacuum.Vacuum") as mock_vacuum_cls: + with patch("homeassistant.components.xiaomi_miio.Vacuum") as mock_vacuum_cls: mock_vacuum_cls.return_value = mock_vacuum yield mock_vacuum -async def test_xiaomi_exceptions(hass, caplog, mock_mirobo_is_on): +async def test_xiaomi_exceptions(hass, mock_mirobo_is_on): """Test error logging on exceptions.""" entity_name = "test_vacuum_cleaner_error" entity_id = await setup_component(hass, entity_name) @@ -204,53 +212,39 @@ async def test_xiaomi_exceptions(hass, caplog, mock_mirobo_is_on): return state.state != STATE_UNAVAILABLE # The initial setup has to be done successfully - assert "Initializing with host 192.168.1.100 (token 12345...)" in caplog.text - assert "WARNING" not in caplog.text assert is_available() # Second update causes an exception, which should be logged mock_mirobo_is_on.status.side_effect = DeviceException("dummy exception") - await hass.helpers.entity_component.async_update_entity(entity_id) - assert "WARNING" in caplog.text - assert "Got exception while fetching the state" in caplog.text + future = dt_util.utcnow() + timedelta(seconds=60) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert not is_available() # Third update does not get logged as the device is already unavailable, # so we clear the log and reset the status to test that - caplog.clear() mock_mirobo_is_on.status.reset_mock() + future += timedelta(seconds=60) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - await hass.helpers.entity_component.async_update_entity(entity_id) - assert "Got exception while fetching the state" not in caplog.text assert not is_available() assert mock_mirobo_is_on.status.call_count == 1 -async def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_got_error): +async def test_xiaomi_vacuum_services(hass, mock_mirobo_is_got_error): """Test vacuum supported features.""" entity_name = "test_vacuum_cleaner_1" entity_id = await setup_component(hass, entity_name) - assert "Initializing with host 192.168.1.100 (token 12345...)" in caplog.text - # Check state attributes state = hass.states.get(entity_id) assert state.state == STATE_ERROR assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 - assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_ON - assert state.attributes.get(ATTR_DO_NOT_DISTURB_START) == "22:00:00" - assert state.attributes.get(ATTR_DO_NOT_DISTURB_END) == "06:00:00" assert state.attributes.get(ATTR_ERROR) == "Error message" assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-80" - assert state.attributes.get(ATTR_CLEANING_TIME) == 155 - assert state.attributes.get(ATTR_CLEANED_AREA) == 123 - assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 12 - assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 12 - assert state.attributes.get(ATTR_FILTER_LEFT) == 12 - assert state.attributes.get(ATTR_CLEANING_COUNT) == 35 - assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 123 - assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 695 assert state.attributes.get(ATTR_TIMERS) == [ { "enabled": True, @@ -274,6 +268,13 @@ async def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_got_error): mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() + await hass.services.async_call( + DOMAIN, SERVICE_PAUSE, {"entity_id": entity_id}, blocking=True + ) + mock_mirobo_is_got_error.assert_has_calls([mock.call.pause()], any_order=True) + mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) + mock_mirobo_is_got_error.reset_mock() + await hass.services.async_call( DOMAIN, SERVICE_STOP, {"entity_id": entity_id}, blocking=True ) @@ -327,28 +328,121 @@ async def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_got_error): mock_mirobo_is_got_error.reset_mock() -async def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): +@pytest.mark.parametrize( + "error, status_calls", + [(None, STATUS_CALLS), (DeviceException("dummy exception"), [])], +) +@pytest.mark.parametrize( + "service, service_data, device_method, device_method_call", + [ + ( + SERVICE_START_REMOTE_CONTROL, + {ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2"}, + "manual_start", + mock.call(), + ), + ( + SERVICE_MOVE_REMOTE_CONTROL, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + "duration": 1000, + "rotation": -40, + "velocity": -0.1, + }, + "manual_control", + mock.call( + **{ + "duration": 1000, + "rotation": -40, + "velocity": -0.1, + } + ), + ), + ( + SERVICE_STOP_REMOTE_CONTROL, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + }, + "manual_stop", + mock.call(), + ), + ( + SERVICE_MOVE_REMOTE_CONTROL_STEP, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + "duration": 2000, + "rotation": 120, + "velocity": 0.1, + }, + "manual_control_once", + mock.call( + **{ + "duration": 2000, + "rotation": 120, + "velocity": 0.1, + } + ), + ), + ( + SERVICE_CLEAN_ZONE, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + "zone": [[123, 123, 123, 123]], + "repeats": 2, + }, + "zoned_clean", + mock.call([[123, 123, 123, 123, 2]]), + ), + ( + SERVICE_GOTO, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + "x_coord": 25500, + "y_coord": 26500, + }, + "goto", + mock.call(x_coord=25500, y_coord=26500), + ), + ( + SERVICE_CLEAN_SEGMENT, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + "segments": ["1", "2"], + }, + "segment_clean", + mock.call(segments=[int(i) for i in ["1", "2"]]), + ), + ( + SERVICE_CLEAN_SEGMENT, + { + ATTR_ENTITY_ID: "vacuum.test_vacuum_cleaner_2", + "segments": 1, + }, + "segment_clean", + mock.call(segments=[1]), + ), + ], +) +async def test_xiaomi_specific_services( + hass, + mock_mirobo_is_on, + service, + service_data, + device_method, + device_method_call, + error, + status_calls, +): """Test vacuum supported features.""" entity_name = "test_vacuum_cleaner_2" entity_id = await setup_component(hass, entity_name) - assert "Initializing with host 192.168.1.100 (token 12345" in caplog.text - # Check state attributes state = hass.states.get(entity_id) assert state.state == STATE_CLEANING assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 14204 - assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_OFF assert state.attributes.get(ATTR_ERROR) is None assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-30" - assert state.attributes.get(ATTR_CLEANING_TIME) == 175 - assert state.attributes.get(ATTR_CLEANED_AREA) == 133 - assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 11 - assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 11 - assert state.attributes.get(ATTR_FILTER_LEFT) == 11 - assert state.attributes.get(ATTR_CLEANING_COUNT) == 41 - assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 323 - assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 675 assert state.attributes.get(ATTR_TIMERS) == [ { "enabled": True, @@ -363,64 +457,18 @@ async def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): ] # Xiaomi vacuum specific services: - await hass.services.async_call( - XIAOMI_DOMAIN, - SERVICE_START_REMOTE_CONTROL, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - - mock_mirobo_is_on.assert_has_calls([mock.call.manual_start()], any_order=True) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - mock_mirobo_is_on.reset_mock() - - control = {"duration": 1000, "rotation": -40, "velocity": -0.1} - await hass.services.async_call( - XIAOMI_DOMAIN, - SERVICE_MOVE_REMOTE_CONTROL, - {**control, ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_mirobo_is_on.manual_control.assert_has_calls( - [mock.call(**control)], any_order=True - ) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - mock_mirobo_is_on.reset_mock() + device_method_attr = getattr(mock_mirobo_is_on, device_method) + device_method_attr.side_effect = error await hass.services.async_call( XIAOMI_DOMAIN, - SERVICE_STOP_REMOTE_CONTROL, - {ATTR_ENTITY_ID: entity_id}, + service, + service_data, blocking=True, ) - mock_mirobo_is_on.assert_has_calls([mock.call.manual_stop()], any_order=True) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - mock_mirobo_is_on.reset_mock() - control_once = {"duration": 2000, "rotation": 120, "velocity": 0.1} - await hass.services.async_call( - XIAOMI_DOMAIN, - SERVICE_MOVE_REMOTE_CONTROL_STEP, - {**control_once, ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_mirobo_is_on.manual_control_once.assert_has_calls( - [mock.call(**control_once)], any_order=True - ) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - mock_mirobo_is_on.reset_mock() - - control = {"zone": [[123, 123, 123, 123]], "repeats": 2} - await hass.services.async_call( - XIAOMI_DOMAIN, - SERVICE_CLEAN_ZONE, - {**control, ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - mock_mirobo_is_on.zoned_clean.assert_has_calls( - [mock.call([[123, 123, 123, 123, 2]])], any_order=True - ) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) + device_method_attr.assert_has_calls([device_method_call], any_order=True) + mock_mirobo_is_on.assert_has_calls(status_calls, any_order=True) mock_mirobo_is_on.reset_mock() @@ -429,8 +477,6 @@ async def test_xiaomi_vacuum_fanspeeds(hass, caplog, mock_mirobo_fanspeeds): entity_name = "test_vacuum_cleaner_2" entity_id = await setup_component(hass, entity_name) - assert "Initializing with host 192.168.1.100 (token 12345" in caplog.text - state = hass.states.get(entity_id) assert state.attributes.get(ATTR_FAN_SPEED) == "Silent" fanspeeds = state.attributes.get(ATTR_FAN_SPEED_LIST) @@ -474,51 +520,6 @@ async def test_xiaomi_vacuum_fanspeeds(hass, caplog, mock_mirobo_fanspeeds): assert "Fan speed step not recognized" in caplog.text -async def test_xiaomi_vacuum_goto_service(hass, caplog, mock_mirobo_is_on): - """Test vacuum supported features.""" - entity_name = "test_vacuum_cleaner_2" - entity_id = await setup_component(hass, entity_name) - - data = {"entity_id": entity_id, "x_coord": 25500, "y_coord": 25500} - await hass.services.async_call(XIAOMI_DOMAIN, SERVICE_GOTO, data, blocking=True) - mock_mirobo_is_on.goto.assert_has_calls( - [mock.call(x_coord=data["x_coord"], y_coord=data["y_coord"])], any_order=True - ) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - - -async def test_xiaomi_vacuum_clean_segment_service(hass, caplog, mock_mirobo_is_on): - """Test vacuum supported features.""" - entity_name = "test_vacuum_cleaner_2" - entity_id = await setup_component(hass, entity_name) - - data = {"entity_id": entity_id, "segments": ["1", "2"]} - await hass.services.async_call( - XIAOMI_DOMAIN, SERVICE_CLEAN_SEGMENT, data, blocking=True - ) - mock_mirobo_is_on.segment_clean.assert_has_calls( - [mock.call(segments=[int(i) for i in data["segments"]])], any_order=True - ) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - - -async def test_xiaomi_vacuum_clean_segment_service_single_segment( - hass, caplog, mock_mirobo_is_on -): - """Test vacuum supported features.""" - entity_name = "test_vacuum_cleaner_2" - entity_id = await setup_component(hass, entity_name) - - data = {"entity_id": entity_id, "segments": 1} - await hass.services.async_call( - XIAOMI_DOMAIN, SERVICE_CLEAN_SEGMENT, data, blocking=True - ) - mock_mirobo_is_on.segment_clean.assert_has_calls( - [mock.call(segments=[data["segments"]])], any_order=True - ) - mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) - - async def setup_component(hass, entity_name): """Set up vacuum component.""" entity_id = f"{DOMAIN}.{entity_name}" @@ -528,11 +529,11 @@ async def setup_component(hass, entity_name): unique_id="123456", title=entity_name, data={ - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + CONF_FLOW_TYPE: CONF_DEVICE, CONF_HOST: "192.168.1.100", CONF_TOKEN: "12345678901234567890123456789012", - const.CONF_MODEL: const.MODELS_VACUUM[0], - const.CONF_MAC: TEST_MAC, + CONF_MODEL: MODELS_VACUUM[0], + CONF_MAC: TEST_MAC, }, )