Refactor Xiaomi vacuum to sensors (#54990)
* Refactor Xiaomi vacuum with sensors. This is the first step into refactoring xiaomi vacuum attributes into sensors. What is missing are some switches and binary sensors etc. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Use generic sensor for Xiaomi vacuum sensors. By using HA coordinator, the generic Xiaomi sensor class can be used with these coordinators to get the status sensors from vacuum. This also means now that sensors are available as soon as HA starts, which is a nice plus. Now the only reason to create a subclass of the generic sensors is when custom value parsing needs to be done. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Working vacuum sensors via 1 coordinator. Vacuum needs a custom coordinator to ensure that it fetches all the needed data and puts it in a dict. From this dict the sensors will then get their data accordingly. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Remove vacuum setup method in sensor Sensor is generic enough that vacuum does not require its own setup method. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Don't auto register generic sensors. Let the user decide which sensor is useful for them and enable them. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Remove converted attributes from xiaomi vacuum. The attributes that have been converted so far should be removed from the vacuum attributes list. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Fetch data from vacuum sequentially. It seems some vacuums do not like parallel requests. The requests that came before are ignored. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Refactor vacuum sensors to its own container. By moving vacuum sensors to its own container, there is no more key collisions. This in turns means that there is need for the split hack to ensure key names are correct. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * fixup! fix bad rebase. Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Fix sensor naming and default registration. Use proper names for sensors, no need to include from which device status it came. Registration of the sensor by default has been parameterised. If the param is not set, the sensor is not registered. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Make vacuum platform also use coordinator for its data. By using the coordinator for data in vacuum platfrom, removes the cases where request gets ignored during the case where the requests are done concurrently by separate platforms. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Add binary sensor for vacuum Add binary sensor for waterbox, mop, and water shortage. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Added proper icons to sensors. https://github.com/home-assistant/core/issues/51361 Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Refactor sensors to use dataclass. Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Make vacuum use coordinator for its data. This commit also insures that the binary sensors are only registered for devices that include a mop. Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Minor refactoring Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Update data from coordinator after running command. This is is to have a faster status change when executing a command like changing fan speeds. If a manual refresh is not triggered. Worst case scenario it will take 10s for the new fan speed to be reported in HA. Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Refresh within coroutine is ok. Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Move logging to _handle_coordinator_update Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Use internal state attribute. Signed-off-by: Kevin Hellemun <17928966+OGKevin@users.noreply.github.com> * Fix vacuum typing * Fix tests constants * Fix vacuum inconsistent return values * Fix vacuum state update * Fix vacuum tests * Remove impossible cases * Parametrize custom services test * Move goto test * Move clean segment test * Move clean single segment test * Test service pause * Include vacuum in coverage * Delint * Fix vacuum sensor dict collision. This also prevents collision for unique id. As the key is used to construct unique ids. * Use f strings as dict keys Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
c651cff6a0
commit
dc5e4392ae
10 changed files with 664 additions and 364 deletions
|
@ -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/*
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
"""Tests for the Xiaomi Miio integration."""
|
||||
TEST_MAC = "ab:cd:ef:gh:ij:kl"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue