diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py index 8f8ef4134a9..661e4489f74 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.BUTTON, Platform.NUMBER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py new file mode 100644 index 00000000000..e558349b8f7 --- /dev/null +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -0,0 +1,78 @@ +"""Binary sensor entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from gotailwind.models import TailwindDoor + +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 .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +@dataclass(kw_only=True, frozen=True) +class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tailwind door binary sensor entities.""" + + is_on_fn: Callable[[TailwindDoor], bool] + + +DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( + TailwindDoorBinarySensorEntityDescription( + key="locked_out", + translation_key="operational_status", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:garage-alert", + is_on_fn=lambda door: not door.locked_out, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind binary sensor based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorBinarySensorEntity(coordinator, description, door_id) + for description in DESCRIPTIONS + for door_id in coordinator.data.doors + ) + + +class TailwindDoorBinarySensorEntity(TailwindDoorEntity, BinarySensorEntity): + """Representation of a Tailwind door binary sensor entity.""" + + entity_description: TailwindDoorBinarySensorEntityDescription + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + description: TailwindDoorBinarySensorEntityDescription, + door_id: str, + ) -> None: + """Initiate Tailwind button entity.""" + super().__init__(coordinator, door_id) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.device_id}-{door_id}-{description.key}" + ) + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn( + self.coordinator.data.doors[self.door_id] + ) diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py index 1077e2eb888..e4b18d5e4da 100644 --- a/homeassistant/components/tailwind/entity.py +++ b/homeassistant/components/tailwind/entity.py @@ -23,3 +23,28 @@ class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): model=coordinator.data.product, sw_version=coordinator.data.firmware_version, ) + + +class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind door entity. + + These are the entities that belong to a specific garage door opener + that is connected via the Tailwind controller. + """ + + _attr_has_entity_name = True + + def __init__( + self, coordinator: TailwindDataUpdateCoordinator, door_id: str + ) -> None: + """Initialize an Tailwind door entity.""" + self.door_id = door_id + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, + via_device=(DOMAIN, coordinator.data.device_id), + name=f"Door {coordinator.data.doors[door_id].index+1}", + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json index bc765efa8d1..de5a025cbce 100644 --- a/homeassistant/components/tailwind/strings.json +++ b/homeassistant/components/tailwind/strings.json @@ -46,6 +46,15 @@ } }, "entity": { + "binary_sensor": { + "operational_status": { + "name": "Operational status", + "state": { + "off": "Locked out", + "on": "Operational" + } + } + }, "number": { "brightness": { "name": "Status LED brightness" diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..18145d0274e --- /dev/null +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 1 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_1_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.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_number_entities.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Door 2 Operational status', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_number_entities.4 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_2_operational_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational status', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_status', + 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.5 + 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_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py new file mode 100644 index 00000000000..1a8269e8457 --- /dev/null +++ b/tests/components/tailwind/test_binary_sensor.py @@ -0,0 +1,31 @@ +"""Tests for binary sensor entities provided by the Tailwind integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor entities provided by the Tailwind integration.""" + for entity_id in ( + "binary_sensor.door_1_operational_status", + "binary_sensor.door_2_operational_status", + ): + assert (state := hass.states.get(entity_id)) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry