Add listeners for roborock (#103651)

* Add listeners for roborock

* add tests

* decrease test complexity
This commit is contained in:
Luke Lashley 2023-11-19 19:24:43 -05:00 committed by GitHub
parent f8e3f1497c
commit 6ef194f992
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 78 additions and 1 deletions

View File

@ -5,7 +5,7 @@ from typing import Any
from roborock.api import AttributeCache, RoborockClient from roborock.api import AttributeCache, RoborockClient
from roborock.cloud_api import RoborockMqttClient from roborock.cloud_api import RoborockMqttClient
from roborock.command_cache import CacheableAttribute from roborock.command_cache import CacheableAttribute
from roborock.containers import Status from roborock.containers import Consumable, Status
from roborock.exceptions import RoborockException from roborock.exceptions import RoborockException
from roborock.roborock_typing import RoborockCommand from roborock.roborock_typing import RoborockCommand
@ -97,3 +97,12 @@ class RoborockCoordinatedEntity(
res = await super().send(command, params) res = await super().send(command, params)
await self.coordinator.async_refresh() await self.coordinator.async_refresh()
return res return res
def _update_from_listener(self, value: Status | Consumable):
"""Update the status or consumable data from a listener and then write the new entity state."""
if isinstance(value, Status):
self.coordinator.roborock_device_info.props.status = value
else:
self.coordinator.roborock_device_info.props.consumable = value
self.coordinator.data = self.coordinator.roborock_device_info.props
self.async_write_ha_state()

View File

@ -3,6 +3,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from roborock.containers import Status from roborock.containers import Status
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand from roborock.roborock_typing import RoborockCommand
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
@ -37,6 +38,8 @@ class RoborockSelectDescription(
): ):
"""Class to describe an Roborock select entity.""" """Class to describe an Roborock select entity."""
protocol_listener: RoborockDataProtocol | None = None
SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
RoborockSelectDescription( RoborockSelectDescription(
@ -49,6 +52,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
if data.water_box_mode is not None if data.water_box_mode is not None
else None, else None,
parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)], parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)],
protocol_listener=RoborockDataProtocol.WATER_BOX_MODE,
), ),
RoborockSelectDescription( RoborockSelectDescription(
key="mop_mode", key="mop_mode",
@ -105,6 +109,8 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity):
self.entity_description = entity_description self.entity_description = entity_description
super().__init__(unique_id, coordinator) super().__init__(unique_id, coordinator)
self._attr_options = options self._attr_options = options
if (protocol := self.entity_description.protocol_listener) is not None:
self.api.add_listener(protocol, self._update_from_listener, self.api.cache)
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Set the option.""" """Set the option."""

View File

@ -11,6 +11,7 @@ from roborock.containers import (
RoborockErrorCode, RoborockErrorCode,
RoborockStateCode, RoborockStateCode,
) )
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import DeviceProp from roborock.roborock_typing import DeviceProp
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -48,6 +49,8 @@ class RoborockSensorDescription(
): ):
"""A class that describes Roborock sensors.""" """A class that describes Roborock sensors."""
protocol_listener: RoborockDataProtocol | None = None
def _dock_error_value_fn(properties: DeviceProp) -> str | None: def _dock_error_value_fn(properties: DeviceProp) -> str | None:
if ( if (
@ -67,6 +70,7 @@ SENSOR_DESCRIPTIONS = [
translation_key="main_brush_time_left", translation_key="main_brush_time_left",
value_fn=lambda data: data.consumable.main_brush_time_left, value_fn=lambda data: data.consumable.main_brush_time_left,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
protocol_listener=RoborockDataProtocol.MAIN_BRUSH_WORK_TIME,
), ),
RoborockSensorDescription( RoborockSensorDescription(
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
@ -76,6 +80,7 @@ SENSOR_DESCRIPTIONS = [
translation_key="side_brush_time_left", translation_key="side_brush_time_left",
value_fn=lambda data: data.consumable.side_brush_time_left, value_fn=lambda data: data.consumable.side_brush_time_left,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
protocol_listener=RoborockDataProtocol.SIDE_BRUSH_WORK_TIME,
), ),
RoborockSensorDescription( RoborockSensorDescription(
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
@ -85,6 +90,7 @@ SENSOR_DESCRIPTIONS = [
translation_key="filter_time_left", translation_key="filter_time_left",
value_fn=lambda data: data.consumable.filter_time_left, value_fn=lambda data: data.consumable.filter_time_left,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
protocol_listener=RoborockDataProtocol.FILTER_WORK_TIME,
), ),
RoborockSensorDescription( RoborockSensorDescription(
native_unit_of_measurement=UnitOfTime.SECONDS, native_unit_of_measurement=UnitOfTime.SECONDS,
@ -120,6 +126,7 @@ SENSOR_DESCRIPTIONS = [
value_fn=lambda data: data.status.state_name, value_fn=lambda data: data.status.state_name,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
options=RoborockStateCode.keys(), options=RoborockStateCode.keys(),
protocol_listener=RoborockDataProtocol.STATE,
), ),
RoborockSensorDescription( RoborockSensorDescription(
key="cleaning_area", key="cleaning_area",
@ -145,6 +152,7 @@ SENSOR_DESCRIPTIONS = [
value_fn=lambda data: data.status.error_code_name, value_fn=lambda data: data.status.error_code_name,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
options=RoborockErrorCode.keys(), options=RoborockErrorCode.keys(),
protocol_listener=RoborockDataProtocol.ERROR_CODE,
), ),
RoborockSensorDescription( RoborockSensorDescription(
key="battery", key="battery",
@ -152,6 +160,7 @@ SENSOR_DESCRIPTIONS = [
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY, device_class=SensorDeviceClass.BATTERY,
protocol_listener=RoborockDataProtocol.BATTERY,
), ),
RoborockSensorDescription( RoborockSensorDescription(
key="last_clean_start", key="last_clean_start",
@ -238,6 +247,8 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity):
"""Initialize the entity.""" """Initialize the entity."""
super().__init__(unique_id, coordinator) super().__init__(unique_id, coordinator)
self.entity_description = description self.entity_description = description
if (protocol := self.entity_description.protocol_listener) is not None:
self.api.add_listener(protocol, self._update_from_listener, self.api.cache)
@property @property
def native_value(self) -> StateType | datetime.datetime: def native_value(self) -> StateType | datetime.datetime:

View File

@ -2,6 +2,7 @@
from typing import Any from typing import Any
from roborock.code_mappings import RoborockStateCode from roborock.code_mappings import RoborockStateCode
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand from roborock.roborock_typing import RoborockCommand
from homeassistant.components.vacuum import ( from homeassistant.components.vacuum import (
@ -94,6 +95,12 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
StateVacuumEntity.__init__(self) StateVacuumEntity.__init__(self)
RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) RoborockCoordinatedEntity.__init__(self, unique_id, coordinator)
self._attr_fan_speed_list = self._device_status.fan_power_options self._attr_fan_speed_list = self._device_status.fan_power_options
self.api.add_listener(
RoborockDataProtocol.FAN_POWER, self._update_from_listener, self.api.cache
)
self.api.add_listener(
RoborockDataProtocol.STATE, self._update_from_listener, self.api.cache
)
@property @property
def state(self) -> str | None: def state(self) -> str | None:

View File

@ -1,14 +1,20 @@
"""Test Roborock Sensors.""" """Test Roborock Sensors."""
from unittest.mock import patch
from roborock import DeviceData, HomeDataDevice
from roborock.cloud_api import RoborockMqttClient
from roborock.const import ( from roborock.const import (
FILTER_REPLACE_TIME, FILTER_REPLACE_TIME,
MAIN_BRUSH_REPLACE_TIME, MAIN_BRUSH_REPLACE_TIME,
SENSOR_DIRTY_REPLACE_TIME, SENSOR_DIRTY_REPLACE_TIME,
SIDE_BRUSH_REPLACE_TIME, SIDE_BRUSH_REPLACE_TIME,
) )
from roborock.roborock_message import RoborockMessage, RoborockMessageProtocol
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .mock_data import CONSUMABLE, STATUS, USER_DATA
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -47,3 +53,41 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non
hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state
== "2023-01-01T03:43:58+00:00" == "2023-01-01T03:43:58+00:00"
) )
async def test_listener_update(
hass: HomeAssistant, setup_entry: MockConfigEntry
) -> None:
"""Test that when we receive a mqtt topic, we successfully update the entity."""
assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging"
# Listeners are global based on uuid - so this is okay
client = RoborockMqttClient(
USER_DATA, DeviceData(device=HomeDataDevice("abc123", "", "", "", ""), model="")
)
# Test Status
with patch("roborock.api.AttributeCache.value", STATUS.as_dict()):
# Symbolizes a mqtt message coming in
client.on_message_received(
[
RoborockMessage(
protocol=RoborockMessageProtocol.GENERAL_REQUEST,
payload=b'{"t": 1699464794, "dps": {"121": 5}}',
)
]
)
# Test consumable
assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str(
FILTER_REPLACE_TIME - 74382
)
with patch("roborock.api.AttributeCache.value", CONSUMABLE.as_dict()):
client.on_message_received(
[
RoborockMessage(
protocol=RoborockMessageProtocol.GENERAL_REQUEST,
payload=b'{"t": 1699464794, "dps": {"127": 743}}',
)
]
)
assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str(
FILTER_REPLACE_TIME - 743
)