diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 1ad7ed31571..4d97e8c5fac 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -15,7 +15,12 @@ from .coordinator import RokuDataUpdateCoordinator CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.REMOTE] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.MEDIA_PLAYER, + Platform.REMOTE, + Platform.SENSOR, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py new file mode 100644 index 00000000000..ce1128ebb0e --- /dev/null +++ b/homeassistant/components/roku/sensor.py @@ -0,0 +1,78 @@ +"""Support for Roku sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from rokuecp.models import Device as RokuDevice + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +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 .coordinator import RokuDataUpdateCoordinator +from .entity import RokuEntity + + +@dataclass +class RokuSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[RokuDevice], str | None] + + +@dataclass +class RokuSensorEntityDescription( + SensorEntityDescription, RokuSensorEntityDescriptionMixin +): + """Describes Roku sensor entity.""" + + +SENSORS: tuple[RokuSensorEntityDescription, ...] = ( + RokuSensorEntityDescription( + key="active_app", + name="Active App", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:application", + value_fn=lambda device: device.app.name if device.app else None, + ), + RokuSensorEntityDescription( + key="active_app_id", + name="Active App ID", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:application-cog", + value_fn=lambda device: device.app.app_id if device.app else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roku sensor based on a config entry.""" + coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + unique_id = coordinator.data.info.serial_number + async_add_entities( + RokuSensorEntity( + device_id=unique_id, + coordinator=coordinator, + description=description, + ) + for description in SENSORS + ) + + +class RokuSensorEntity(RokuEntity, SensorEntity): + """Defines a Roku sensor entity.""" + + entity_description: RokuSensorEntityDescription + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 4da59050b08..c71525fcd73 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -128,6 +128,7 @@ async def test_idle_setup( await setup_integration(hass, aioclient_mock, power=False) state = hass.states.get(MAIN_ENTITY_ID) + assert state assert state.state == STATE_STANDBY diff --git a/tests/components/roku/test_sensor.py b/tests/components/roku/test_sensor.py new file mode 100644 index 00000000000..670cf69a8dc --- /dev/null +++ b/tests/components/roku/test_sensor.py @@ -0,0 +1,115 @@ +"""Tests for the sensors provided by the Roku integration.""" +from homeassistant.components.roku.const import DOMAIN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, + ATTR_ICON, + STATE_UNKNOWN, +) +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_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku sensors.""" + await setup_integration(hass, aioclient_mock) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.my_roku_3_active_app") + entry = entity_registry.async_get("sensor.my_roku_3_active_app") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_active_app" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "Roku" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App" + assert state.attributes.get(ATTR_ICON) == "mdi:application" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.my_roku_3_active_app_id") + entry = entity_registry.async_get("sensor.my_roku_3_active_app_id") + assert entry + assert state + assert entry.unique_id == f"{UPNP_SERIAL}_active_app_id" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Roku 3 Active App ID" + assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" + 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.connections == { + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fb"), + (dr.CONNECTION_NETWORK_MAC, "b0:a7:37:96:4d:fa"), + } + 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_sensors( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test the Roku TV 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("sensor.58_onn_roku_tv_active_app") + entry = entity_registry.async_get("sensor.58_onn_roku_tv_active_app") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_active_app" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "Antenna TV" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App' + assert state.attributes.get(ATTR_ICON) == "mdi:application" + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.58_onn_roku_tv_active_app_id") + entry = entity_registry.async_get("sensor.58_onn_roku_tv_active_app_id") + assert entry + assert state + assert entry.unique_id == "YN00H5555555_active_app_id" + assert entry.entity_category == EntityCategory.DIAGNOSTIC + assert state.state == "tvinput.dtv" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == '58" Onn Roku TV Active App ID' + assert state.attributes.get(ATTR_ICON) == "mdi:application-cog" + 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.connections == { + (dr.CONNECTION_NETWORK_MAC, "d8:13:99:f8:b0:c6"), + (dr.CONNECTION_NETWORK_MAC, "d4:3a:2e:07:fd:cb"), + } + 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"