From 58d0420a6b5f4b71a25e806f1629334dfdf085c4 Mon Sep 17 00:00:00 2001 From: belangp <583452+belangp@users.noreply.github.com> Date: Mon, 4 Mar 2024 12:20:20 -0400 Subject: [PATCH] Add Hyperion sensor to report active priority on each instance (#102333) * Implement code review comments * Update homeassistant/components/hyperion/sensor.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/hyperion/__init__.py | 2 +- homeassistant/components/hyperion/const.py | 3 + homeassistant/components/hyperion/sensor.py | 210 ++++++++++++++++++ .../components/hyperion/strings.json | 5 + tests/components/hyperion/test_light.py | 11 + tests/components/hyperion/test_sensor.py | 178 +++++++++++++++ 6 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/hyperion/sensor.py create mode 100644 tests/components/hyperion/test_sensor.py diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 42d9770656b..58eaedb3ff9 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -32,7 +32,7 @@ from .const import ( SIGNAL_INSTANCE_REMOVE, ) -PLATFORMS = [Platform.CAMERA, Platform.LIGHT, Platform.SWITCH] +PLATFORMS = [Platform.CAMERA, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 77e16df4d72..3d44dd35e08 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -28,3 +28,6 @@ SIGNAL_ENTITY_REMOVE = f"{DOMAIN}_entity_remove_signal.{{}}" TYPE_HYPERION_CAMERA = "hyperion_camera" TYPE_HYPERION_LIGHT = "hyperion_light" TYPE_HYPERION_COMPONENT_SWITCH_BASE = "hyperion_component_switch" + +TYPE_HYPERION_SENSOR_BASE = "hyperion_sensor" +TYPE_HYPERION_SENSOR_VISIBLE_PRIORITY = "visible_priority" diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py new file mode 100644 index 00000000000..f0d1c6b1314 --- /dev/null +++ b/homeassistant/components/hyperion/sensor.py @@ -0,0 +1,210 @@ +"""Sensor platform for Hyperion.""" +from __future__ import annotations + +import functools +from typing import Any + +from hyperion import client +from hyperion.const import ( + KEY_COMPONENTID, + KEY_ORIGIN, + KEY_OWNER, + KEY_PRIORITIES, + KEY_PRIORITY, + KEY_RGB, + KEY_UPDATE, + KEY_VALUE, + KEY_VISIBLE, +) + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ( + get_hyperion_device_id, + get_hyperion_unique_id, + listen_for_instance_updates, +) +from .const import ( + CONF_INSTANCE_CLIENTS, + DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, + SIGNAL_ENTITY_REMOVE, + TYPE_HYPERION_SENSOR_BASE, + TYPE_HYPERION_SENSOR_VISIBLE_PRIORITY, +) + +SENSORS = [TYPE_HYPERION_SENSOR_VISIBLE_PRIORITY] +PRIORITY_SENSOR_DESCRIPTION = SensorEntityDescription( + key="visible_priority", + translation_key="visible_priority", + icon="mdi:lava-lamp", +) + + +def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str: + """Calculate a sensor's unique_id.""" + return get_hyperion_unique_id( + server_id, + instance_num, + f"{TYPE_HYPERION_SENSOR_BASE}_{suffix}", + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Hyperion platform from config entry.""" + entry_data = hass.data[DOMAIN][config_entry.entry_id] + server_id = config_entry.unique_id + + @callback + def instance_add(instance_num: int, instance_name: str) -> None: + """Add entities for a new Hyperion instance.""" + assert server_id + sensors = [ + HyperionVisiblePrioritySensor( + server_id, + instance_num, + instance_name, + entry_data[CONF_INSTANCE_CLIENTS][instance_num], + PRIORITY_SENSOR_DESCRIPTION, + ) + ] + + async_add_entities(sensors) + + @callback + def instance_remove(instance_num: int) -> None: + """Remove entities for an old Hyperion instance.""" + assert server_id + + for sensor in SENSORS: + async_dispatcher_send( + hass, + SIGNAL_ENTITY_REMOVE.format( + _sensor_unique_id(server_id, instance_num, sensor), + ), + ) + + listen_for_instance_updates(hass, config_entry, instance_add, instance_remove) + + +class HyperionSensor(SensorEntity): + """Sensor class.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + server_id: str, + instance_num: int, + instance_name: str, + hyperion_client: client.HyperionClient, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = entity_description + self._client = hyperion_client + self._attr_native_value = None + self._client_callbacks: dict[str, Any] = {} + + device_id = get_hyperion_device_id(server_id, instance_num) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=instance_name, + configuration_url=self._client.remote_url, + ) + + @property + def available(self) -> bool: + """Return server availability.""" + return bool(self._client.has_loaded_state) + + async def async_added_to_hass(self) -> None: + """Register callbacks when entity added to hass.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), + functools.partial(self.async_remove, force_remove=True), + ) + ) + + self._client.add_callbacks(self._client_callbacks) + + async def async_will_remove_from_hass(self) -> None: + """Cleanup prior to hass removal.""" + self._client.remove_callbacks(self._client_callbacks) + + +class HyperionVisiblePrioritySensor(HyperionSensor): + """Class that displays the visible priority of a Hyperion instance.""" + + def __init__( + self, + server_id: str, + instance_num: int, + instance_name: str, + hyperion_client: client.HyperionClient, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + + super().__init__( + server_id, instance_num, instance_name, hyperion_client, entity_description + ) + + self._attr_unique_id = _sensor_unique_id( + server_id, instance_num, TYPE_HYPERION_SENSOR_VISIBLE_PRIORITY + ) + + self._client_callbacks = { + f"{KEY_PRIORITIES}-{KEY_UPDATE}": self._update_priorities + } + + @callback + def _update_priorities(self, _: dict[str, Any] | None = None) -> None: + """Update Hyperion priorities.""" + state_value = None + attrs = {} + + for priority in self._client.priorities or []: + if not (KEY_VISIBLE in priority and priority[KEY_VISIBLE] is True): + continue + + if priority[KEY_COMPONENTID] == "COLOR": + state_value = priority[KEY_VALUE][KEY_RGB] + else: + state_value = priority[KEY_OWNER] + + attrs = { + "component_id": priority[KEY_COMPONENTID], + "origin": priority[KEY_ORIGIN], + "priority": priority[KEY_PRIORITY], + "owner": priority[KEY_OWNER], + } + + if priority[KEY_COMPONENTID] == "COLOR": + attrs["color"] = priority[KEY_VALUE] + else: + attrs["color"] = None + + self._attr_native_value = state_value + self._attr_extra_state_attributes = attrs + + self.async_write_ha_state() diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index 8d7e3751c4c..79c226b71eb 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -80,6 +80,11 @@ "usb_capture": { "name": "Component USB capture" } + }, + "sensor": { + "visible_priority": { + "name": "Visible priority" + } } } } diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 4715441a5de..0dd2ad9fc94 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -338,6 +338,7 @@ async def test_light_async_turn_on(hass: HomeAssistant) -> None: const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: (0, 255, 255)}, + const.KEY_OWNER: "System", } ] @@ -432,6 +433,7 @@ async def test_light_async_turn_on(hass: HomeAssistant) -> None: const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: (0, 0, 255)}, + const.KEY_OWNER: "System", } ] call_registered_callback(client, "priorities-update") @@ -564,6 +566,8 @@ async def test_light_async_updates_from_hyperion_client( const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COMPONENTID: const.KEY_COMPONENTID_EFFECT, const.KEY_OWNER: effect, + const.KEY_VISIBLE: True, + const.KEY_ORIGIN: "System", } ] @@ -581,6 +585,9 @@ async def test_light_async_updates_from_hyperion_client( const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: rgb}, + const.KEY_VISIBLE: True, + const.KEY_ORIGIN: "System", + const.KEY_OWNER: "System", } ] @@ -625,6 +632,9 @@ async def test_light_async_updates_from_hyperion_client( const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: rgb}, + const.KEY_VISIBLE: True, + const.KEY_ORIGIN: "System", + const.KEY_OWNER: "System", } ] call_registered_callback(client, "client-update", {"loaded-state": True}) @@ -645,6 +655,7 @@ async def test_full_state_loaded_on_start(hass: HomeAssistant) -> None: const.KEY_PRIORITY: TEST_PRIORITY, const.KEY_COMPONENTID: const.KEY_COMPONENTID_COLOR, const.KEY_VALUE: {const.KEY_RGB: (0, 100, 100)}, + const.KEY_OWNER: "System", } ] client.effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}] diff --git a/tests/components/hyperion/test_sensor.py b/tests/components/hyperion/test_sensor.py new file mode 100644 index 00000000000..65991b4b7e1 --- /dev/null +++ b/tests/components/hyperion/test_sensor.py @@ -0,0 +1,178 @@ +"""Tests for the Hyperion integration.""" + +from hyperion.const import ( + KEY_ACTIVE, + KEY_COMPONENTID, + KEY_ORIGIN, + KEY_OWNER, + KEY_PRIORITY, + KEY_RGB, + KEY_VALUE, + KEY_VISIBLE, +) + +from homeassistant.components.hyperion import get_hyperion_device_id +from homeassistant.components.hyperion.const import ( + DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from . import ( + TEST_CONFIG_ENTRY_ID, + TEST_INSTANCE, + TEST_INSTANCE_1, + TEST_SYSINFO_ID, + call_registered_callback, + create_mock_client, + setup_test_config_entry, +) + +TEST_COMPONENTS = [ + {"enabled": True, "name": "VISIBLE_PRIORITY"}, +] + +TEST_SENSOR_BASE_ENTITY_ID = "sensor.test_instance_1" +TEST_VISIBLE_EFFECT_SENSOR_ID = "sensor.test_instance_1_visible_priority" + + +async def test_sensor_has_correct_entities(hass: HomeAssistant) -> None: + """Test that the correct sensor entities are created.""" + client = create_mock_client() + client.components = TEST_COMPONENTS + await setup_test_config_entry(hass, hyperion_client=client) + + for component in TEST_COMPONENTS: + name = slugify(component["name"]) + entity_id = f"{TEST_SENSOR_BASE_ENTITY_ID}_{name}" + entity_state = hass.states.get(entity_id) + assert entity_state, f"Couldn't find entity: {entity_id}" + + +async def test_device_info(hass: HomeAssistant) -> None: + """Verify device information includes expected details.""" + client = create_mock_client() + client.components = TEST_COMPONENTS + await setup_test_config_entry(hass, hyperion_client=client) + + device_identifer = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device(identifiers={(DOMAIN, device_identifer)}) + assert device + assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.identifiers == {(DOMAIN, device_identifer)} + assert device.manufacturer == HYPERION_MANUFACTURER_NAME + assert device.model == HYPERION_MODEL_NAME + assert device.name == TEST_INSTANCE_1["friendly_name"] + + entity_registry = er.async_get(hass) + entities_from_device = [ + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, device.id) + ] + + for component in TEST_COMPONENTS: + name = slugify(component["name"]) + entity_id = TEST_SENSOR_BASE_ENTITY_ID + "_" + name + assert entity_id in entities_from_device + + +async def test_visible_effect_state_changes(hass: HomeAssistant) -> None: + """Verify that state changes are processed as expected for visible effect sensor.""" + client = create_mock_client() + client.components = TEST_COMPONENTS + await setup_test_config_entry(hass, hyperion_client=client) + + # Simulate a platform grabber effect state callback from Hyperion. + client.priorities = [ + { + KEY_ACTIVE: True, + KEY_COMPONENTID: "GRABBER", + KEY_ORIGIN: "System", + KEY_OWNER: "X11", + KEY_PRIORITY: 250, + KEY_VISIBLE: True, + } + ] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_VISIBLE_EFFECT_SENSOR_ID) + assert entity_state + assert entity_state.state == client.priorities[0][KEY_OWNER] + assert ( + entity_state.attributes["component_id"] == client.priorities[0][KEY_COMPONENTID] + ) + assert entity_state.attributes["origin"] == client.priorities[0][KEY_ORIGIN] + assert entity_state.attributes["priority"] == client.priorities[0][KEY_PRIORITY] + + # Simulate an effect state callback from Hyperion. + client.priorities = [ + { + KEY_ACTIVE: True, + KEY_COMPONENTID: "EFFECT", + KEY_ORIGIN: "System", + KEY_OWNER: "Warm mood blobs", + KEY_PRIORITY: 250, + KEY_VISIBLE: True, + } + ] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_VISIBLE_EFFECT_SENSOR_ID) + assert entity_state + assert entity_state.state == client.priorities[0][KEY_OWNER] + assert ( + entity_state.attributes["component_id"] == client.priorities[0][KEY_COMPONENTID] + ) + assert entity_state.attributes["origin"] == client.priorities[0][KEY_ORIGIN] + assert entity_state.attributes["priority"] == client.priorities[0][KEY_PRIORITY] + + # Simulate a USB Capture state callback from Hyperion. + client.priorities = [ + { + KEY_ACTIVE: True, + KEY_COMPONENTID: "V4L", + KEY_ORIGIN: "System", + KEY_OWNER: "V4L2", + KEY_PRIORITY: 250, + KEY_VISIBLE: True, + } + ] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_VISIBLE_EFFECT_SENSOR_ID) + assert entity_state + assert entity_state.state == client.priorities[0][KEY_OWNER] + assert ( + entity_state.attributes["component_id"] == client.priorities[0][KEY_COMPONENTID] + ) + assert entity_state.attributes["origin"] == client.priorities[0][KEY_ORIGIN] + assert entity_state.attributes["priority"] == client.priorities[0][KEY_PRIORITY] + + # Simulate a color effect state callback from Hyperion. + client.priorities = [ + { + KEY_ACTIVE: True, + KEY_COMPONENTID: "COLOR", + KEY_ORIGIN: "System", + KEY_OWNER: "System", + KEY_PRIORITY: 250, + KEY_VALUE: {KEY_RGB: [0, 0, 0]}, + KEY_VISIBLE: True, + } + ] + + call_registered_callback(client, "priorities-update") + entity_state = hass.states.get(TEST_VISIBLE_EFFECT_SENSOR_ID) + assert entity_state + assert entity_state.state == str(client.priorities[0][KEY_VALUE][KEY_RGB]) + assert ( + entity_state.attributes["component_id"] == client.priorities[0][KEY_COMPONENTID] + ) + assert entity_state.attributes["origin"] == client.priorities[0][KEY_ORIGIN] + assert entity_state.attributes["priority"] == client.priorities[0][KEY_PRIORITY] + assert entity_state.attributes["color"] == client.priorities[0][KEY_VALUE]