Add binary_sensor to Ecovacs (#108544)

This commit is contained in:
Robert Resch 2024-01-22 13:36:26 +01:00 committed by GitHub
parent 0d8afc72c2
commit 881872fdb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 420 additions and 23 deletions

View File

@ -25,6 +25,7 @@ CONFIG_SCHEMA = vol.Schema(
) )
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.VACUUM, Platform.VACUUM,
] ]

View File

@ -0,0 +1,75 @@
"""Binary sensor module."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic
from deebot_client.capabilities import CapabilityEvent
from deebot_client.events.water_info import WaterInfoEvent
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .controller import EcovacsController
from .entity import EcovacsDescriptionEntity, EcovacsEntityDescription, EventT
@dataclass(kw_only=True, frozen=True)
class EcovacsBinarySensorEntityDescription(
BinarySensorEntityDescription,
EcovacsEntityDescription,
Generic[EventT],
):
"""Class describing Deebot binary sensor entity."""
value_fn: Callable[[EventT], bool | None]
ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = (
EcovacsBinarySensorEntityDescription[WaterInfoEvent](
capability_fn=lambda caps: caps.water,
value_fn=lambda e: e.mop_attached,
key="mop_attached",
translation_key="mop_attached",
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id]
controller.register_platform_add_entities(
EcovacsBinarySensor, ENTITY_DESCRIPTIONS, async_add_entities
)
class EcovacsBinarySensor(
EcovacsDescriptionEntity[
CapabilityEvent[EventT], EcovacsBinarySensorEntityDescription
],
BinarySensorEntity,
):
"""Ecovacs binary sensor."""
entity_description: EcovacsBinarySensorEntityDescription
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_event(event: EventT) -> None:
self._attr_is_on = self.entity_description.value_fn(event)
self.async_write_ha_state()
self._subscribe(self._capability.event, on_event)

View File

@ -23,7 +23,9 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import EcovacsDescriptionEntity, EcovacsEntityDescription
from .util import get_client_device_id from .util import get_client_device_id
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -86,6 +88,23 @@ class EcovacsController:
_LOGGER.debug("Controller initialize complete") _LOGGER.debug("Controller initialize complete")
def register_platform_add_entities(
self,
entity_class: type[EcovacsDescriptionEntity],
descriptions: tuple[EcovacsEntityDescription, ...],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create entities from descriptions and add them."""
new_entites: list[EcovacsDescriptionEntity] = []
for device in self.devices:
for description in descriptions:
if capability := description.capability_fn(device.capabilities):
new_entites.append(entity_class(device, capability, description))
if new_entites:
async_add_entities(new_entites)
async def teardown(self) -> None: async def teardown(self) -> None:
"""Disconnect controller.""" """Disconnect controller."""
for device in self.devices: for device in self.devices:

View File

@ -104,3 +104,18 @@ class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]):
""" """
for event_type in self._subscribed_events: for event_type in self._subscribed_events:
self._device.events.request_refresh(event_type) self._device.events.request_refresh(event_type)
class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT, _EntityDescriptionT]):
"""Ecovacs entity."""
def __init__(
self,
device: Device,
capability: CapabilityT,
entity_description: _EntityDescriptionT,
**kwargs: Any,
) -> None:
"""Initialize entity."""
self.entity_description = entity_description
super().__init__(device, capability, **kwargs)

View File

@ -0,0 +1,12 @@
{
"entity": {
"binary_sensor": {
"mop_attached": {
"default": "mdi:water-off",
"state": {
"on": "mdi:water"
}
}
}
}
}

View File

@ -19,6 +19,11 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"mop_attached": {
"name": "Mop attached"
}
},
"vacuum": { "vacuum": {
"vacuum": { "vacuum": {
"state_attributes": { "state_attributes": {

View File

@ -1,17 +1,21 @@
"""Common fixtures for the Ecovacs tests.""" """Common fixtures for the Ecovacs tests."""
from collections.abc import Generator from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from deebot_client.api_client import ApiClient from deebot_client.const import PATH_API_APPSVR_APP
from deebot_client.authentication import Authenticator from deebot_client.device import Device
from deebot_client.exceptions import ApiError
from deebot_client.models import Credentials from deebot_client.models import Credentials
import pytest import pytest
from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.const import DOMAIN
from homeassistant.components.ecovacs.controller import EcovacsController
from homeassistant.core import HomeAssistant
from .const import VALID_ENTRY_DATA from .const import VALID_ENTRY_DATA
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture @pytest.fixture
@ -34,18 +38,43 @@ def mock_config_entry() -> MockConfigEntry:
@pytest.fixture @pytest.fixture
def mock_authenticator() -> Generator[Mock, None, None]: def device_classes() -> list[str]:
"""Device classes, which should be returned by the get_devices api call."""
return ["yna5x1"]
@pytest.fixture
def mock_authenticator(device_classes: list[str]) -> Generator[Mock, None, None]:
"""Mock the authenticator.""" """Mock the authenticator."""
mock_authenticator = Mock(spec_set=Authenticator)
mock_authenticator.authenticate.return_value = Credentials("token", "user_id", 0)
with patch( with patch(
"homeassistant.components.ecovacs.controller.Authenticator", "homeassistant.components.ecovacs.controller.Authenticator",
return_value=mock_authenticator, autospec=True,
), patch( ) as mock, patch(
"homeassistant.components.ecovacs.config_flow.Authenticator", "homeassistant.components.ecovacs.config_flow.Authenticator",
return_value=mock_authenticator, new=mock,
): ):
yield mock_authenticator authenticator = mock.return_value
authenticator.authenticate.return_value = Credentials("token", "user_id", 0)
devices = []
for device_class in device_classes:
devices.append(
load_json_object_fixture(f"devices/{device_class}/device.json", DOMAIN)
)
def post_authenticated(
path: str,
json: dict[str, Any],
*,
query_params: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
) -> dict[str, Any]:
if path == PATH_API_APPSVR_APP:
return {"code": 0, "devices": devices, "errno": "0"}
raise ApiError("Path not mocked: {path}")
authenticator.post_authenticated.side_effect = post_authenticated
yield authenticator
@pytest.fixture @pytest.fixture
@ -55,10 +84,46 @@ def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock:
@pytest.fixture @pytest.fixture
def mock_api_client(mock_authenticator: Mock) -> Mock: def mock_mqtt_client(mock_authenticator: Mock) -> Mock:
"""Mock the API client.""" """Mock the MQTT client."""
with patch( with patch(
"homeassistant.components.ecovacs.controller.ApiClient", "homeassistant.components.ecovacs.controller.MqttClient",
return_value=Mock(spec_set=ApiClient), autospec=True,
) as mock_api_client: ) as mock_mqtt_client:
yield mock_api_client.return_value client = mock_mqtt_client.return_value
client._authenticator = mock_authenticator
client.subscribe.return_value = lambda: None
yield client
@pytest.fixture
def mock_device_execute() -> AsyncMock:
"""Mock the device execute function."""
with patch.object(
Device, "_execute_command", return_value=True
) as mock_device_execute:
yield mock_device_execute
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_authenticator: Mock,
mock_mqtt_client: Mock,
mock_device_execute: AsyncMock,
) -> MockConfigEntry:
"""Set up the Ecovacs integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@pytest.fixture
def controller(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> EcovacsController:
"""Get the controller for the config entry."""
return hass.data[DOMAIN][init_integration.entry_id]

View File

@ -0,0 +1,22 @@
{
"did": "E1234567890000000001",
"name": "E1234567890000000001",
"class": "yna5xi",
"resource": "upQ6",
"company": "eco-ng",
"service": {
"jmq": "jmq-ngiot-eu.dc.ww.ecouser.net",
"mqs": "api-ngiot.dc-as.ww.ecouser.net"
},
"deviceName": "DEEBOT OZMO 950 Series",
"icon": "https://portal-ww.ecouser.net/api/pim/file/get/606278df4a84d700082b39f1",
"UILogicId": "DX_9G",
"materialNo": "110-1820-0101",
"pid": "5c19a91ca1e6ee000178224a",
"product_category": "DEEBOT",
"model": "DX9G",
"nick": "Ozmo 950",
"homeSort": 9999,
"status": 1,
"otaUpgrade": {}
}

View File

@ -0,0 +1,63 @@
# serializer version: 1
# name: test_mop_attached[binary_sensor.ozmo_950_mop_attached-entity_entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ozmo_950_mop_attached',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Mop attached',
'platform': 'ecovacs',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'mop_attached',
'unique_id': 'E1234567890000000001_mop_attached',
'unit_of_measurement': None,
})
# ---
# name: test_mop_attached[binary_sensor.ozmo_950_mop_attached-state]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ozmo_950_mop_attached',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Mop attached',
'platform': 'ecovacs',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'mop_attached',
'unique_id': 'E1234567890000000001_mop_attached',
'unit_of_measurement': None,
})
# ---

View File

@ -0,0 +1,29 @@
# serializer version: 1
# name: test_devices_in_dr[E1234567890000000001]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'ecovacs',
'E1234567890000000001',
),
}),
'is_new': False,
'manufacturer': 'Ecovacs',
'model': 'DEEBOT OZMO 950 Series',
'name': 'Ozmo 950',
'name_by_user': None,
'serial_number': 'E1234567890000000001',
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@ -0,0 +1,46 @@
"""Tests for Ecovacs binary sensors."""
from deebot_client.event_bus import EventBus
from deebot_client.events import WaterAmount, WaterInfoEvent
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.ecovacs.controller import EcovacsController
from homeassistant.const import STATE_OFF, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .util import notify_and_wait
pytestmark = [pytest.mark.usefixtures("init_integration")]
async def test_mop_attached(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
controller: EcovacsController,
snapshot: SnapshotAssertion,
) -> None:
"""Test mop_attached binary sensor."""
entity_id = "binary_sensor.ozmo_950_mop_attached"
assert (state := hass.states.get(entity_id))
assert state.state == STATE_UNKNOWN
assert (entity_entry := entity_registry.async_get(state.entity_id))
assert entity_entry == snapshot(name=f"{entity_id}-entity_entry")
assert entity_entry.device_id
event_bus: EventBus = controller.devices[0].events
await notify_and_wait(
hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=True)
)
assert (state := hass.states.get(state.entity_id))
assert entity_entry == snapshot(name=f"{entity_id}-state")
await notify_and_wait(
hass, event_bus, WaterInfoEvent(WaterAmount.HIGH, mop_attached=False)
)
assert (state := hass.states.get(state.entity_id))
assert state.state == STATE_OFF

View File

@ -1,13 +1,16 @@
"""Test init of ecovacs.""" """Test init of ecovacs."""
from typing import Any from typing import Any
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock, patch
from deebot_client.exceptions import DeebotError, InvalidAuthenticationError from deebot_client.exceptions import DeebotError, InvalidAuthenticationError
import pytest import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.const import DOMAIN
from homeassistant.components.ecovacs.controller import EcovacsController
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .const import IMPORT_DATA from .const import IMPORT_DATA
@ -15,22 +18,31 @@ from .const import IMPORT_DATA
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_api_client") @pytest.mark.usefixtures("init_integration")
async def test_load_unload_config_entry( async def test_load_unload_config_entry(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, init_integration: MockConfigEntry,
) -> None: ) -> None:
"""Test loading and unloading the integration.""" """Test loading and unloading the integration."""
mock_config_entry.add_to_hass(hass) mock_config_entry = init_integration
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.state is ConfigEntryState.LOADED
assert DOMAIN in hass.data
await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert DOMAIN not in hass.data
@pytest.fixture
def mock_api_client(mock_authenticator: Mock) -> Mock:
"""Mock the API client."""
with patch(
"homeassistant.components.ecovacs.controller.ApiClient",
autospec=True,
) as mock_api_client:
yield mock_api_client.return_value
async def test_config_entry_not_ready( async def test_config_entry_not_ready(
@ -83,3 +95,18 @@ async def test_async_setup_import(
assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected
assert mock_setup_entry.call_count == config_entries_expected assert mock_setup_entry.call_count == config_entries_expected
assert mock_authenticator_authenticate.call_count == config_entries_expected assert mock_authenticator_authenticate.call_count == config_entries_expected
async def test_devices_in_dr(
device_registry: dr.DeviceRegistry,
controller: EcovacsController,
snapshot: SnapshotAssertion,
) -> None:
"""Test all devices are in the device registry."""
for device in controller.devices:
assert (
device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, device.device_info.did)}
)
)
assert device_entry == snapshot(name=device.device_info.did)

View File

@ -0,0 +1,18 @@
"""Ecovacs test util."""
import asyncio
from deebot_client.event_bus import EventBus
from deebot_client.events import Event
from homeassistant.core import HomeAssistant
async def notify_and_wait(
hass: HomeAssistant, event_bus: EventBus, event: Event
) -> None:
"""Block till done."""
event_bus.notify(event)
await asyncio.gather(*event_bus._tasks)
await hass.async_block_till_done()