diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 2c59c998b65..1ad7ed31571 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -15,7 +15,7 @@ from .coordinator import RokuDataUpdateCoordinator CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.REMOTE] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py new file mode 100644 index 00000000000..f7b40def249 --- /dev/null +++ b/homeassistant/components/roku/binary_sensor.py @@ -0,0 +1,93 @@ +"""Support for Roku binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from rokuecp.models import Device as RokuDevice + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import RokuEntity + + +@dataclass +class RokuBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[RokuDevice], bool | None] + + +@dataclass +class RokuBinarySensorEntityDescription( + BinarySensorEntityDescription, RokuBinarySensorEntityDescriptionMixin +): + """Describes a Roku binary sensor entity.""" + + +BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = ( + RokuBinarySensorEntityDescription( + key="headphones_connected", + name="Headphones Connected", + icon="mdi:headphones", + value_fn=lambda device: device.info.headphones_connected, + ), + RokuBinarySensorEntityDescription( + key="supports_airplay", + name="Supports AirPlay", + icon="mdi:cast-variant", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.supports_airplay, + ), + RokuBinarySensorEntityDescription( + key="supports_ethernet", + name="Supports Ethernet", + icon="mdi:ethernet", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.ethernet_support, + ), + RokuBinarySensorEntityDescription( + key="supports_find_remote", + name="Supports Find Remote", + icon="mdi:remote", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.info.supports_find_remote, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Roku binary sensors based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + unique_id = coordinator.data.info.serial_number + async_add_entities( + RokuBinarySensorEntity( + device_id=unique_id, + coordinator=coordinator, + description=description, + ) + for description in BINARY_SENSORS + ) + + +class RokuBinarySensorEntity(RokuEntity, BinarySensorEntity): + """Defines a Roku binary sensor.""" + + entity_description: RokuBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return bool(self.entity_description.value_fn(self.coordinator.data)) diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index baaf7a4b175..17a39a17609 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -1,7 +1,7 @@ """Base Entity for Roku.""" from __future__ import annotations -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RokuDataUpdateCoordinator @@ -14,12 +14,21 @@ class RokuEntity(CoordinatorEntity): coordinator: RokuDataUpdateCoordinator def __init__( - self, *, device_id: str, coordinator: RokuDataUpdateCoordinator + self, + *, + device_id: str, + coordinator: RokuDataUpdateCoordinator, + description: EntityDescription | None = None, ) -> None: """Initialize the Roku entity.""" super().__init__(coordinator) self._device_id = device_id + if description is not None: + self.entity_description = description + self._attr_name = f"{coordinator.data.info.name} {description.name}" + self._attr_unique_id = f"{device_id}_{description.key}" + @property def device_info(self) -> DeviceInfo: """Return device information about this Roku device.""" diff --git a/tests/components/roku/fixtures/rokutv-device-info.xml b/tests/components/roku/fixtures/rokutv-device-info.xml index 658fc130629..cbb538ba4c1 100644 --- a/tests/components/roku/fixtures/rokutv-device-info.xml +++ b/tests/components/roku/fixtures/rokutv-device-info.xml @@ -59,6 +59,7 @@ true true true + true true true https://www.onntvsupport.com/ diff --git a/tests/components/roku/test_binary_sensor.py b/tests/components/roku/test_binary_sensor.py new file mode 100644 index 00000000000..e4d9ccad675 --- /dev/null +++ b/tests/components/roku/test_binary_sensor.py @@ -0,0 +1,161 @@ +"""Tests for the sensors provided by the Roku integration.""" +from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON +from homeassistant.components.roku.const import DOMAIN +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from tests.components.roku import UPNP_SERIAL, setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_roku_binary_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku binary sensors.""" + await setup_integration(hass, aioclient_mock) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.my_roku_3_headphones_connected") + entry = entity_registry.async_get("binary_sensor.my_roku_3_headphones_connected") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_headphones_connected" + assert entry.entity_category is None + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Headphones Connected" + assert state.attributes.get(ATTR_ICON) == "mdi:headphones" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.my_roku_3_supports_airplay") + entry = entity_registry.async_get("binary_sensor.my_roku_3_supports_airplay") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_supports_airplay" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports AirPlay" + assert state.attributes.get(ATTR_ICON) == "mdi:cast-variant" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.my_roku_3_supports_ethernet") + entry = entity_registry.async_get("binary_sensor.my_roku_3_supports_ethernet") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_supports_ethernet" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Ethernet" + assert state.attributes.get(ATTR_ICON) == "mdi:ethernet" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.my_roku_3_supports_find_remote") + entry = entity_registry.async_get("binary_sensor.my_roku_3_supports_find_remote") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_supports_find_remote" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Supports Find Remote" + assert state.attributes.get(ATTR_ICON) == "mdi:remote" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, UPNP_SERIAL)} + assert device_entry.manufacturer == "Roku" + assert device_entry.model == "Roku 3" + assert device_entry.name == "My Roku 3" + assert device_entry.entry_type is None + assert device_entry.sw_version == "7.5.0" + + +async def test_rokutv_binary_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku binary sensors.""" + await setup_integration( + hass, + aioclient_mock, + device="rokutv", + app="tvinput-dtv", + host="192.168.1.161", + unique_id="YN00H5555555", + ) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("binary_sensor.58_onn_roku_tv_headphones_connected") + entry = entity_registry.async_get( + "binary_sensor.58_onn_roku_tv_headphones_connected" + ) + assert entry + assert state + assert entry.unique_id == "YN00H5555555_headphones_connected" + assert entry.entity_category is None + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == '58" Onn Roku TV Headphones Connected' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:headphones" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.58_onn_roku_tv_supports_airplay") + entry = entity_registry.async_get("binary_sensor.58_onn_roku_tv_supports_airplay") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_supports_airplay" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports AirPlay' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:cast-variant" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.58_onn_roku_tv_supports_ethernet") + entry = entity_registry.async_get("binary_sensor.58_onn_roku_tv_supports_ethernet") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_supports_ethernet" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Supports Ethernet' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:ethernet" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("binary_sensor.58_onn_roku_tv_supports_find_remote") + entry = entity_registry.async_get( + "binary_sensor.58_onn_roku_tv_supports_find_remote" + ) + assert entry + assert state + assert entry.unique_id == "YN00H5555555_supports_find_remote" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == '58" Onn Roku TV Supports Find Remote' + ) + assert state.attributes.get(ATTR_ICON) == "mdi:remote" + assert ATTR_DEVICE_CLASS not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, "YN00H5555555")} + assert device_entry.manufacturer == "Onn" + assert device_entry.model == "100005844" + assert device_entry.name == '58" Onn Roku TV' + assert device_entry.entry_type is None + assert device_entry.sw_version == "9.2.0"