diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 661e4489f74..b71fd1cd0fc 100644 --- a/homeassistant/components/tailwind/__init__.py +++ b/homeassistant/components/tailwind/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .coordinator import TailwindDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.BUTTON, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py new file mode 100644 index 00000000000..5a1f9cb8d73 --- /dev/null +++ b/homeassistant/components/tailwind/cover.py @@ -0,0 +1,88 @@ +"""Cover entity platform for Tailwind.""" +from __future__ import annotations + +from typing import Any + +from gotailwind import TailwindDoorOperationCommand, TailwindDoorState + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind cover based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorCoverEntity(coordinator, door_id) + for door_id in coordinator.data.doors + ) + + +class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): + """Representation of a Tailwind door binary sensor entity.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_is_closing = False + _attr_is_opening = False + _attr_name = None + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + door_id: str, + ) -> None: + """Initiate Tailwind button entity.""" + super().__init__(coordinator, door_id) + self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" + + @property + def is_closed(self) -> bool: + """Return if the cover is closed or not.""" + return ( + self.coordinator.data.doors[self.door_id].state == TailwindDoorState.CLOSED + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door. + + The Tailwind operating command will await the confirmation of the + door being opened before returning. + """ + self._attr_is_opening = True + self.async_write_ha_state() + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.OPEN, + ) + self._attr_is_opening = False + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door. + + The Tailwind operating command will await the confirmation of the + door being closed before returning. + """ + self._attr_is_closing = True + self.async_write_ha_state() + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.CLOSE, + ) + self._attr_is_closing = False + await self.coordinator.async_request_refresh() diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr new file mode 100644 index 00000000000..4e94c1084e4 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_cover_entities[cover.door_1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_1', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door1', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- +# name: test_cover_entities[cover.door_2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_2', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door2', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_cover.py b/tests/components/tailwind/test_cover.py new file mode 100644 index 00000000000..e13ab534e5b --- /dev/null +++ b/tests/components/tailwind/test_cover.py @@ -0,0 +1,76 @@ +"""Tests for cover entities provided by the Tailwind integration.""" +from unittest.mock import ANY, MagicMock + +from gotailwind import TailwindDoorOperationCommand +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + "entity_id", + [ + "cover.door_1", + "cover.door_2", + ], +) +async def test_cover_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test cover entities provided by the Tailwind integration.""" + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + +async def test_cover_operations( + hass: HomeAssistant, + mock_tailwind: MagicMock, +) -> None: + """Test operating the doors.""" + assert len(mock_tailwind.operate.mock_calls) == 0 + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.OPEN + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.CLOSE + )