diff --git a/CODEOWNERS b/CODEOWNERS index 99e1f2aa0f7..33d954b8740 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,8 +331,8 @@ build.json @home-assistant/supervisor /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 /tests/components/econet/ @w1ll1am23 -/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus -/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus +/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @augar +/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @augar /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli /homeassistant/components/efergy/ @tkdrob diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index ce7222f96a2..945f999cf79 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.IMAGE, + Platform.LAWN_MOWER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py new file mode 100644 index 00000000000..e33e87bc5fb --- /dev/null +++ b/homeassistant/components/ecovacs/lawn_mower.py @@ -0,0 +1,99 @@ +"""Ecovacs mower entity.""" + +from __future__ import annotations + +import logging + +from deebot_client.capabilities import MowerCapabilities +from deebot_client.device import Device +from deebot_client.events import StateEvent +from deebot_client.models import CleanAction, State + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityEntityDescription, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsEntity + +_LOGGER = logging.getLogger(__name__) + + +_STATE_TO_MOWER_STATE = { + State.IDLE: LawnMowerActivity.PAUSED, + State.CLEANING: LawnMowerActivity.MOWING, + State.RETURNING: LawnMowerActivity.MOWING, + State.DOCKED: LawnMowerActivity.DOCKED, + State.ERROR: LawnMowerActivity.ERROR, + State.PAUSED: LawnMowerActivity.PAUSED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ecovacs mowers.""" + mowers: list[EcovacsMower] = [] + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + for device in controller.devices(MowerCapabilities): + mowers.append(EcovacsMower(device)) + _LOGGER.debug("Adding Ecovacs Mowers to Home Assistant: %s", mowers) + async_add_entities(mowers) + + +class EcovacsMower( + EcovacsEntity[MowerCapabilities, MowerCapabilities], + LawnMowerEntity, +): + """Ecovacs Mower.""" + + _attr_supported_features = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING + ) + + entity_description = LawnMowerEntityEntityDescription( + key="mower", translation_key="mower", name=None + ) + + def __init__(self, device: Device[MowerCapabilities]) -> None: + """Initialize the mower.""" + capabilities = device.capabilities + super().__init__(device, capabilities) + + 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_status(event: StateEvent) -> None: + self._attr_activity = _STATE_TO_MOWER_STATE[event.state] + self.async_write_ha_state() + + self._subscribe(self._capability.state.event, on_status) + + async def _clean_command(self, action: CleanAction) -> None: + await self._device.execute_command( + self._capability.clean.action.command(action) + ) + + async def async_start_mowing(self) -> None: + """Resume schedule.""" + await self._clean_command(CleanAction.START) + + async def async_pause(self) -> None: + """Pauses the mower.""" + await self._clean_command(CleanAction.PAUSE) + + async def async_dock(self) -> None: + """Parks the mower until next schedule.""" + await self._device.execute_command(self._capability.charge.execute()) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 837c48b45d8..7c5e527c464 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -1,7 +1,7 @@ { "domain": "ecovacs", "name": "Ecovacs", - "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus"], + "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus", "@augar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", diff --git a/tests/components/ecovacs/fixtures/devices/5xu9h3/device.json b/tests/components/ecovacs/fixtures/devices/5xu9h3/device.json new file mode 100644 index 00000000000..1143e756d40 --- /dev/null +++ b/tests/components/ecovacs/fixtures/devices/5xu9h3/device.json @@ -0,0 +1,22 @@ +{ + "did": "8516fbb1-17f1-4194-0000000", + "name": "E1234567890000000002", + "class": "5xu9h3", + "resource": "cbCt", + "company": "eco-ng", + "service": { + "jmq": "jmq-ngiot-eu.dc.ww.ecouser.net", + "mqs": "api-ngiot.dc-as.ww.ecouser.net" + }, + "deviceName": "GOAT", + "icon": "https://portal-ww.ecouser.net/api/pim/file/get/63913d74df25d049785c3bd7", + "UILogicId": "goat_ww_h_goat", + "materialNo": "116-2201-0001", + "pid": "0000000", + "product_category": "GOATBOT", + "model": "GOAT", + "nick": "Goat G1", + "homeSort": 9999, + "status": 1, + "ota": true +} diff --git a/tests/components/ecovacs/snapshots/test_lawn_mower.ambr b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr new file mode 100644 index 00000000000..9446bb805ac --- /dev/null +++ b/tests/components/ecovacs/snapshots/test_lawn_mower.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_lawn_mower[5xu9h3][lawn_mower.goat_g1-entity_entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lawn_mower', + 'entity_category': None, + 'entity_id': 'lawn_mower.goat_g1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'mower', + 'unique_id': '8516fbb1-17f1-4194-0000000_mower', + 'unit_of_measurement': None, + }) +# --- +# name: test_lawn_mower[5xu9h3][lawn_mower.goat_g1-state] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lawn_mower', + 'entity_category': None, + 'entity_id': 'lawn_mower.goat_g1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ecovacs', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'mower', + 'unique_id': '8516fbb1-17f1-4194-0000000_mower', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 7a67b0716f9..a567ea3253d 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -121,6 +121,7 @@ async def test_devices_in_dr( ("device_fixture", "entities"), [ ("yna5x1", 25), + ("5xu9h3", 14), ], ) async def test_all_entities_loaded( diff --git a/tests/components/ecovacs/test_lawn_mower.py b/tests/components/ecovacs/test_lawn_mower.py new file mode 100644 index 00000000000..563e6aecbb0 --- /dev/null +++ b/tests/components/ecovacs/test_lawn_mower.py @@ -0,0 +1,119 @@ +"""Tests for Ecovacs lawn mower entities.""" + +from dataclasses import dataclass + +from deebot_client.capabilities import MowerCapabilities +from deebot_client.command import Command +from deebot_client.commands.json import Charge, CleanV2 +from deebot_client.events import StateEvent +from deebot_client.models import CleanAction, State +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.ecovacs.const import DOMAIN +from homeassistant.components.ecovacs.controller import EcovacsController +from homeassistant.components.lawn_mower import ( + DOMAIN as PLATFORM_DOMAIN, + LawnMowerActivity, +) +from homeassistant.components.lawn_mower.const import ( + SERVICE_DOCK, + SERVICE_PAUSE, + SERVICE_START_MOWING, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .util import notify_and_wait + +pytestmark = [pytest.mark.usefixtures("init_integration")] + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return Platform.LAWN_MOWER + + +@pytest.mark.parametrize( + ("device_fixture"), + [ + "5xu9h3", + ], +) +async def test_lawn_mower( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + controller: EcovacsController, +) -> None: + """Test lawn mower states.""" + entity_id = "lawn_mower.goat_g1" + 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 + + device = next(controller.devices(MowerCapabilities)) + + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry.identifiers == {(DOMAIN, device.device_info["did"])} + + event_bus = device.events + await notify_and_wait(hass, event_bus, StateEvent(State.CLEANING)) + + assert (state := hass.states.get(state.entity_id)) + assert entity_entry == snapshot(name=f"{entity_id}-state") + assert state.state == LawnMowerActivity.MOWING + + await notify_and_wait(hass, event_bus, StateEvent(State.DOCKED)) + + assert (state := hass.states.get(state.entity_id)) + assert state.state == LawnMowerActivity.DOCKED + + +@dataclass(frozen=True) +class MowerTestCase: + """Mower test.""" + + command: Command + service_name: str + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "tests"), + [ + ( + "5xu9h3", + "lawn_mower.goat_g1", + [ + MowerTestCase(Charge(), SERVICE_DOCK), + MowerTestCase(CleanV2(CleanAction.PAUSE), SERVICE_PAUSE), + MowerTestCase(CleanV2(CleanAction.START), SERVICE_START_MOWING), + ], + ), + ], + ids=["5xu9h3"], +) +async def test_mover_services( + hass: HomeAssistant, + controller: EcovacsController, + entity_id: list[str], + tests: list[MowerTestCase], +) -> None: + """Test mover services.""" + device = next(controller.devices(MowerCapabilities)) + + for test in tests: + device._execute_command.reset_mock() + await hass.services.async_call( + PLATFORM_DOMAIN, + test.service_name, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + device._execute_command.assert_called_with(test.command)