diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index e4c8a96569578d06b7dfdcdd1809020dd762a0e3..6f07b61de4a75eab9c1b6d9754a4754236021b35 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -25,6 +25,7 @@ CONFIG_SCHEMA = vol.Schema( ) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.VACUUM, ] diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..ea22c9de43277882ff5b875bdc425194f8363067 --- /dev/null +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -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) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 645c5b9bc191145cc32e575d471349df24fbff83..78b05a8a7d18937b2c12ff587ce196ef17ab22d1 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -23,7 +23,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady 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 _LOGGER = logging.getLogger(__name__) @@ -86,6 +88,23 @@ class EcovacsController: _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: """Disconnect controller.""" for device in self.devices: diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index caaefef0956f5116a4a0af0f3a039ea94a66d63f..3a2bb03aabb2084a92ab47febeb843de8c2866a8 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -104,3 +104,18 @@ class EcovacsEntity(Entity, Generic[CapabilityT, _EntityDescriptionT]): """ for event_type in self._subscribed_events: 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) diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json new file mode 100644 index 0000000000000000000000000000000000000000..74c27776f64d208be648fcebb158a66789be189b --- /dev/null +++ b/homeassistant/components/ecovacs/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "mop_attached": { + "default": "mdi:water-off", + "state": { + "on": "mdi:water" + } + } + } + } +} diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 2ae12c244a1a958b920cc96d42bed8da20826f4f..6e4c97be360e60dccd31a271fd6e7f90c333cd56 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -19,6 +19,11 @@ } }, "entity": { + "binary_sensor": { + "mop_attached": { + "name": "Mop attached" + } + }, "vacuum": { "vacuum": { "state_attributes": { diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 9ba28857cbea005f8c0e259f09eece35049df334..38ae8ea54ae8b5fc7a206d5dc6d5ea85183b4388 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,17 +1,21 @@ """Common fixtures for the Ecovacs tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, Mock, patch -from deebot_client.api_client import ApiClient -from deebot_client.authentication import Authenticator +from deebot_client.const import PATH_API_APPSVR_APP +from deebot_client.device import Device +from deebot_client.exceptions import ApiError from deebot_client.models import Credentials import pytest 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 tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -34,18 +38,43 @@ def mock_config_entry() -> MockConfigEntry: @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_authenticator = Mock(spec_set=Authenticator) - mock_authenticator.authenticate.return_value = Credentials("token", "user_id", 0) with patch( "homeassistant.components.ecovacs.controller.Authenticator", - return_value=mock_authenticator, - ), patch( + autospec=True, + ) as mock, patch( "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 @@ -55,10 +84,46 @@ def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: @pytest.fixture -def mock_api_client(mock_authenticator: Mock) -> Mock: - """Mock the API client.""" +def mock_mqtt_client(mock_authenticator: Mock) -> Mock: + """Mock the MQTT client.""" with patch( - "homeassistant.components.ecovacs.controller.ApiClient", - return_value=Mock(spec_set=ApiClient), - ) as mock_api_client: - yield mock_api_client.return_value + "homeassistant.components.ecovacs.controller.MqttClient", + autospec=True, + ) as mock_mqtt_client: + 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] diff --git a/tests/components/ecovacs/fixtures/devices/yna5x1/device.json b/tests/components/ecovacs/fixtures/devices/yna5x1/device.json new file mode 100644 index 0000000000000000000000000000000000000000..0b2957af93bc428cc84d79272b7c5b673effa5f2 --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/yna5x1/device.json @@ -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": {} +} diff --git a/tests/components/ecovacs/snapshots/test_binary_sensor.ambr b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000000000000000000000000000000..0ddf8c00a1f9b34dca805b1b6afea4981a389e89 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_binary_sensor.ambr @@ -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, + }) +# --- diff --git a/tests/components/ecovacs/snapshots/test_init.ambr b/tests/components/ecovacs/snapshots/test_init.ambr new file mode 100644 index 0000000000000000000000000000000000000000..c5d3409020482e67aa7bed41e6d5005348432a72 --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_init.ambr @@ -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, + }) +# --- diff --git a/tests/components/ecovacs/test_binary_sensor.py b/tests/components/ecovacs/test_binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..a912df60c62f2977c7cc9ee620ee257d961efc58 --- /dev/null +++ b/tests/components/ecovacs/test_binary_sensor.py @@ -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 diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index e6be4e22233bcff448288db0b6236c17de91e8be..103ab254650435773cedbab26835a44911cdc5fe 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -1,13 +1,16 @@ """Test init of ecovacs.""" from typing import Any -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch from deebot_client.exceptions import DeebotError, InvalidAuthenticationError import pytest +from syrupy import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from .const import IMPORT_DATA @@ -15,22 +18,31 @@ from .const import IMPORT_DATA from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_api_client") +@pytest.mark.usefixtures("init_integration") async def test_load_unload_config_entry( hass: HomeAssistant, - mock_config_entry: MockConfigEntry, + init_integration: MockConfigEntry, ) -> None: """Test loading and unloading the integration.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - + mock_config_entry = init_integration 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.async_block_till_done() 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( @@ -83,3 +95,18 @@ async def test_async_setup_import( assert len(hass.config_entries.async_entries(DOMAIN)) == config_entries_expected assert mock_setup_entry.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) diff --git a/tests/components/ecovacs/util.py b/tests/components/ecovacs/util.py new file mode 100644 index 0000000000000000000000000000000000000000..ba697226ae286fc1e7d23acef3cf2da6b8a5351b --- /dev/null +++ b/tests/components/ecovacs/util.py @@ -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()