From 63d42867e83644c08ca87e68d32f95dd0a8517d0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 13 Apr 2021 01:35:38 -0700 Subject: [PATCH] Add Hyperion device support (#47881) * Add Hyperion device support. * Update to the new typing annotations. * Add device cleanup logic. * Fixes based on the excellent feedback from emontnemery --- homeassistant/components/hyperion/__init__.py | 35 ++-- homeassistant/components/hyperion/const.py | 2 + homeassistant/components/hyperion/light.py | 82 ++++++--- homeassistant/components/hyperion/switch.py | 79 +++++---- tests/components/hyperion/__init__.py | 21 ++- tests/components/hyperion/test_config_flow.py | 9 +- tests/components/hyperion/test_light.py | 132 ++++++++++++--- tests/components/hyperion/test_switch.py | 158 ++++++++++++++---- 8 files changed, 389 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 93f3c35f514..0aa94e13cac 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -15,14 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity_registry import ( - async_entries_for_config_entry, - async_get_registry, -) from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import ( @@ -72,6 +69,11 @@ def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str: return f"{server_id}_{instance}_{name}" +def get_hyperion_device_id(server_id: str, instance: int) -> str: + """Get an id for a Hyperion device/instance.""" + return f"{server_id}_{instance}" + + def split_hyperion_unique_id(unique_id: str) -> tuple[str, int, str] | None: """Split a unique_id into a (server_id, instance, type) tuple.""" data = tuple(unique_id.split("_", 2)) @@ -202,7 +204,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_instances_to_clients_raw(instances: list[dict[str, Any]]) -> None: """Convert instances to Hyperion clients.""" - registry = await async_get_registry(hass) + device_registry = dr.async_get(hass) running_instances: set[int] = set() stopped_instances: set[int] = set() existing_instances = hass.data[DOMAIN][config_entry.entry_id][ @@ -249,15 +251,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass, SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), instance_num ) - # Deregister entities that belong to removed instances. - for entry in async_entries_for_config_entry(registry, config_entry.entry_id): - data = split_hyperion_unique_id(entry.unique_id) - if not data: - continue - if data[0] == server_id and ( - data[1] not in running_instances and data[1] not in stopped_instances - ): - registry.async_remove(entry.entity_id) + # Ensure every device associated with this config entry is still in the list of + # motionEye cameras, otherwise remove the device (and thus entities). + known_devices = { + get_hyperion_device_id(server_id, instance_num) + for instance_num in running_instances | stopped_instances + } + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + for (kind, key) in device_entry.identifiers: + if kind == DOMAIN and key in known_devices: + break + else: + device_registry.async_remove_device(device_entry.id) hyperion_client.set_callbacks( { diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 994ef580c91..87600f7c27b 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -40,6 +40,8 @@ DEFAULT_PRIORITY = 128 DOMAIN = "hyperion" +HYPERION_MANUFACTURER_NAME = "Hyperion" +HYPERION_MODEL_NAME = f"{HYPERION_MANUFACTURER_NAME}-NG" HYPERION_RELEASES_URL = "https://github.com/hyperion-project/hyperion.ng/releases" HYPERION_VERSION_WARN_CUTOFF = "2.0.0-alpha.9" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 248a45ec753..5ab74f1141b 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -26,7 +26,11 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.typing import HomeAssistantType import homeassistant.util.color as color_util -from . import get_hyperion_unique_id, listen_for_instance_updates +from . import ( + get_hyperion_device_id, + get_hyperion_unique_id, + listen_for_instance_updates, +) from .const import ( CONF_EFFECT_HIDE_LIST, CONF_INSTANCE_CLIENTS, @@ -34,6 +38,8 @@ from .const import ( DEFAULT_ORIGIN, DEFAULT_PRIORITY, DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, NAME_SUFFIX_HYPERION_LIGHT, NAME_SUFFIX_HYPERION_PRIORITY_LIGHT, SIGNAL_ENTITY_REMOVE, @@ -85,24 +91,17 @@ async def async_setup_entry( def instance_add(instance_num: int, instance_name: str) -> None: """Add entities for a new Hyperion instance.""" assert server_id + args = ( + server_id, + instance_num, + instance_name, + config_entry.options, + entry_data[CONF_INSTANCE_CLIENTS][instance_num], + ) async_add_entities( [ - HyperionLight( - get_hyperion_unique_id( - server_id, instance_num, TYPE_HYPERION_LIGHT - ), - f"{instance_name} {NAME_SUFFIX_HYPERION_LIGHT}", - config_entry.options, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], - ), - HyperionPriorityLight( - get_hyperion_unique_id( - server_id, instance_num, TYPE_HYPERION_PRIORITY_LIGHT - ), - f"{instance_name} {NAME_SUFFIX_HYPERION_PRIORITY_LIGHT}", - config_entry.options, - entry_data[CONF_INSTANCE_CLIENTS][instance_num], - ), + HyperionLight(*args), + HyperionPriorityLight(*args), ] ) @@ -127,14 +126,17 @@ class HyperionBaseLight(LightEntity): def __init__( self, - unique_id: str, - name: str, + server_id: str, + instance_num: int, + instance_name: str, options: MappingProxyType[str, Any], hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" - self._unique_id = unique_id - self._name = name + self._unique_id = self._compute_unique_id(server_id, instance_num) + self._name = self._compute_name(instance_name) + self._device_id = get_hyperion_device_id(server_id, instance_num) + self._instance_name = instance_name self._options = options self._client = hyperion_client @@ -156,6 +158,14 @@ class HyperionBaseLight(LightEntity): f"{const.KEY_CLIENT}-{const.KEY_UPDATE}": self._update_client, } + def _compute_unique_id(self, server_id: str, instance_num: int) -> str: + """Compute a unique id for this instance.""" + raise NotImplementedError + + def _compute_name(self, instance_name: str) -> str: + """Compute the name of the light.""" + raise NotImplementedError + @property def entity_registry_enabled_default(self) -> bool: """Whether or not the entity is enabled by default.""" @@ -216,6 +226,16 @@ class HyperionBaseLight(LightEntity): """Return a unique id for this instance.""" return self._unique_id + @property + def device_info(self) -> dict[str, Any] | None: + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._instance_name, + "manufacturer": HYPERION_MANUFACTURER_NAME, + "model": HYPERION_MODEL_NAME, + } + def _get_option(self, key: str) -> Any: """Get a value from the provided options.""" defaults = { @@ -412,7 +432,7 @@ class HyperionBaseLight(LightEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self.unique_id), functools.partial(self.async_remove, force_remove=True), ) ) @@ -455,6 +475,14 @@ class HyperionLight(HyperionBaseLight): shown state rather than exclusively the HA priority. """ + def _compute_unique_id(self, server_id: str, instance_num: int) -> str: + """Compute a unique id for this instance.""" + return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) + + def _compute_name(self, instance_name: str) -> str: + """Compute the name of the light.""" + return f"{instance_name} {NAME_SUFFIX_HYPERION_LIGHT}".strip() + @property def is_on(self) -> bool: """Return true if light is on.""" @@ -504,6 +532,16 @@ class HyperionLight(HyperionBaseLight): class HyperionPriorityLight(HyperionBaseLight): """A Hyperion light that only acts on a single Hyperion priority.""" + def _compute_unique_id(self, server_id: str, instance_num: int) -> str: + """Compute a unique id for this instance.""" + return get_hyperion_unique_id( + server_id, instance_num, TYPE_HYPERION_PRIORITY_LIGHT + ) + + def _compute_name(self, instance_name: str) -> str: + """Compute the name of the light.""" + return f"{instance_name} {NAME_SUFFIX_HYPERION_PRIORITY_LIGHT}".strip() + @property def entity_registry_enabled_default(self) -> bool: """Whether or not the entity is enabled by default.""" diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 4a4f8d4da13..dce92df6f35 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -33,11 +33,17 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import slugify -from . import get_hyperion_unique_id, listen_for_instance_updates +from . import ( + get_hyperion_device_id, + get_hyperion_unique_id, + listen_for_instance_updates, +) from .const import ( COMPONENT_TO_NAME, CONF_INSTANCE_CLIENTS, DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, NAME_SUFFIX_HYPERION_COMPONENT_SWITCH, SIGNAL_ENTITY_REMOVE, TYPE_HYPERION_COMPONENT_SWITCH_BASE, @@ -55,6 +61,26 @@ COMPONENT_SWITCHES = [ ] +def _component_to_unique_id(server_id: str, component: str, instance_num: int) -> str: + """Convert a component to a unique_id.""" + return get_hyperion_unique_id( + server_id, + instance_num, + slugify( + f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE} {COMPONENT_TO_NAME[component]}" + ), + ) + + +def _component_to_switch_name(component: str, instance_name: str) -> str: + """Convert a component to a switch name.""" + return ( + f"{instance_name} " + f"{NAME_SUFFIX_HYPERION_COMPONENT_SWITCH} " + f"{COMPONENT_TO_NAME.get(component, component.capitalize())}" + ) + + async def async_setup_entry( hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities: Callable ) -> bool: @@ -62,27 +88,6 @@ async def async_setup_entry( entry_data = hass.data[DOMAIN][config_entry.entry_id] server_id = config_entry.unique_id - def component_to_switch_type(component: str) -> str: - """Convert a component to a switch type string.""" - return slugify( - f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE} {COMPONENT_TO_NAME[component]}" - ) - - def component_to_unique_id(component: str, instance_num: int) -> str: - """Convert a component to a unique_id.""" - assert server_id - return get_hyperion_unique_id( - server_id, instance_num, component_to_switch_type(component) - ) - - def component_to_switch_name(component: str, instance_name: str) -> str: - """Convert a component to a switch name.""" - return ( - f"{instance_name} " - f"{NAME_SUFFIX_HYPERION_COMPONENT_SWITCH} " - f"{COMPONENT_TO_NAME.get(component, component.capitalize())}" - ) - @callback def instance_add(instance_num: int, instance_name: str) -> None: """Add entities for a new Hyperion instance.""" @@ -91,8 +96,9 @@ async def async_setup_entry( for component in COMPONENT_SWITCHES: switches.append( HyperionComponentSwitch( - component_to_unique_id(component, instance_num), - component_to_switch_name(component, instance_name), + server_id, + instance_num, + instance_name, component, entry_data[CONF_INSTANCE_CLIENTS][instance_num], ), @@ -107,7 +113,7 @@ async def async_setup_entry( async_dispatcher_send( hass, SIGNAL_ENTITY_REMOVE.format( - component_to_unique_id(component, instance_num), + _component_to_unique_id(server_id, component, instance_num), ), ) @@ -120,14 +126,19 @@ class HyperionComponentSwitch(SwitchEntity): def __init__( self, - unique_id: str, - name: str, + server_id: str, + instance_num: int, + instance_name: str, component_name: str, hyperion_client: client.HyperionClient, ) -> None: """Initialize the switch.""" - self._unique_id = unique_id - self._name = name + self._unique_id = _component_to_unique_id( + server_id, component_name, instance_num + ) + self._device_id = get_hyperion_device_id(server_id, instance_num) + self._name = _component_to_switch_name(component_name, instance_name) + self._instance_name = instance_name self._component_name = component_name self._client = hyperion_client self._client_callbacks = { @@ -168,6 +179,16 @@ class HyperionComponentSwitch(SwitchEntity): """Return server availability.""" return bool(self._client.has_loaded_state) + @property + def device_info(self) -> dict[str, Any] | None: + """Return device information.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._instance_name, + "manufacturer": HYPERION_MANUFACTURER_NAME, + "model": HYPERION_MODEL_NAME, + } + async def _async_send_set_component(self, value: bool) -> None: """Send a component control request.""" await self._client.async_send_set_component( diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index d0653f88b83..7938527a12d 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -7,9 +7,11 @@ from unittest.mock import AsyncMock, Mock, patch from hyperion import const +from homeassistant.components.hyperion import get_hyperion_unique_id from homeassistant.components.hyperion.const import CONF_PRIORITY, DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import HomeAssistantType from tests.common import MockConfigEntry @@ -20,7 +22,7 @@ TEST_PORT_UI = const.DEFAULT_PORT_UI + 1 TEST_INSTANCE = 1 TEST_ID = "default" TEST_SYSINFO_ID = "f9aab089-f85a-55cf-b7c1-222a72faebe9" -TEST_SYSINFO_VERSION = "2.0.0-alpha.8" +TEST_SYSINFO_VERSION = "2.0.0-alpha.9" TEST_PRIORITY = 180 TEST_ENTITY_ID_1 = "light.test_instance_1" TEST_ENTITY_ID_2 = "light.test_instance_2" @@ -168,3 +170,20 @@ def call_registered_callback( for call in client.add_callbacks.call_args_list: if key in call[0][0]: call[0][0][key](*args, **kwargs) + + +def register_test_entity( + hass: HomeAssistantType, domain: str, type_name: str, entity_id: str +) -> None: + """Register a test entity.""" + unique_id = get_hyperion_unique_id(TEST_SYSINFO_ID, TEST_INSTANCE, type_name) + entity_id = entity_id.split(".")[1] + + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + domain, + DOMAIN, + unique_id, + suggested_object_id=entity_id, + disabled_by=None, + ) diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 7cf0556eddf..381dc018407 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from typing import Any +from typing import Any, Awaitable from unittest.mock import AsyncMock, Mock, patch from hyperion import const @@ -419,13 +419,13 @@ async def test_auth_create_token_approval_declined_task_canceled( class CanceledAwaitableMock(AsyncMock): """A canceled awaitable mock.""" - def __await__(self): + def __await__(self) -> None: raise asyncio.CancelledError mock_task = CanceledAwaitableMock() - task_coro = None + task_coro: Awaitable | None = None - def create_task(arg): + def create_task(arg: Any) -> CanceledAwaitableMock: nonlocal task_coro task_coro = arg return mock_task @@ -453,6 +453,7 @@ async def test_auth_create_token_approval_declined_task_canceled( result = await _configure_flow(hass, result) # This await will advance to the next step. + assert task_coro await task_coro # Assert that cancel is called on the task. diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 505896fbe07..a774a5ba868 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -1,15 +1,22 @@ """Tests for the Hyperion integration.""" from __future__ import annotations +from datetime import timedelta from unittest.mock import AsyncMock, Mock, call, patch from hyperion import const -from homeassistant.components.hyperion import light as hyperion_light +from homeassistant.components.hyperion import ( + get_hyperion_device_id, + light as hyperion_light, +) from homeassistant.components.hyperion.const import ( CONF_EFFECT_HIDE_LIST, DEFAULT_ORIGIN, DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, + TYPE_HYPERION_PRIORITY_LIGHT, ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -19,6 +26,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ( ENTRY_STATE_SETUP_ERROR, + RELOAD_AFTER_UPDATE_DELAY, SOURCE_REAUTH, ConfigEntry, ) @@ -31,18 +39,21 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt import homeassistant.util.color as color_util from . import ( TEST_AUTH_NOT_REQUIRED_RESP, TEST_AUTH_REQUIRED_RESP, + TEST_CONFIG_ENTRY_ID, TEST_ENTITY_ID_1, TEST_ENTITY_ID_2, TEST_ENTITY_ID_3, TEST_HOST, TEST_ID, + TEST_INSTANCE, TEST_INSTANCE_1, TEST_INSTANCE_2, TEST_INSTANCE_3, @@ -53,9 +64,12 @@ from . import ( add_test_config_entry, call_registered_callback, create_mock_client, + register_test_entity, setup_test_config_entry, ) +from tests.common import async_fire_time_changed + COLOR_BLACK = color_util.COLORS["black"] @@ -814,11 +828,13 @@ async def test_priority_light_async_updates( client = create_mock_client() client.priorities = [{**priority_template}] - with patch( - "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - await setup_test_config_entry(hass, hyperion_client=client) + register_test_entity( + hass, + LIGHT_DOMAIN, + TYPE_HYPERION_PRIORITY_LIGHT, + TEST_PRIORITY_LIGHT_ENTITY_ID_1, + ) + await setup_test_config_entry(hass, hyperion_client=client) # == Scenario: Color at HA priority will show light as on. entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) @@ -974,11 +990,13 @@ async def test_priority_light_async_updates_off_sets_black( } ] - with patch( - "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - await setup_test_config_entry(hass, hyperion_client=client) + register_test_entity( + hass, + LIGHT_DOMAIN, + TYPE_HYPERION_PRIORITY_LIGHT, + TEST_PRIORITY_LIGHT_ENTITY_ID_1, + ) + await setup_test_config_entry(hass, hyperion_client=client) client.async_send_clear = AsyncMock(return_value=True) client.async_send_set_color = AsyncMock(return_value=True) @@ -1026,11 +1044,13 @@ async def test_priority_light_prior_color_preserved_after_black( client.priorities = [] client.visible_priority = None - with patch( - "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - await setup_test_config_entry(hass, hyperion_client=client) + register_test_entity( + hass, + LIGHT_DOMAIN, + TYPE_HYPERION_PRIORITY_LIGHT, + TEST_PRIORITY_LIGHT_ENTITY_ID_1, + ) + await setup_test_config_entry(hass, hyperion_client=client) # Turn the light on full green... # On (=), 100% (=), solid (=), [0,0,255] (=) @@ -1132,11 +1152,13 @@ async def test_priority_light_has_no_external_sources(hass: HomeAssistantType) - client = create_mock_client() client.priorities = [] - with patch( - "homeassistant.components.hyperion.light.HyperionPriorityLight.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - await setup_test_config_entry(hass, hyperion_client=client) + register_test_entity( + hass, + LIGHT_DOMAIN, + TYPE_HYPERION_PRIORITY_LIGHT, + TEST_PRIORITY_LIGHT_ENTITY_ID_1, + ) + await setup_test_config_entry(hass, hyperion_client=client) entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) assert entity_state @@ -1153,9 +1175,75 @@ async def test_light_option_effect_hide_list(hass: HomeAssistantType) -> None: ) entity_state = hass.states.get(TEST_ENTITY_ID_1) + assert entity_state assert entity_state.attributes["effect_list"] == [ "Solid", "BOBLIGHTSERVER", "GRABBER", "One", ] + + +async def test_device_info(hass: HomeAssistantType) -> None: + """Verify device information includes expected details.""" + client = create_mock_client() + + register_test_entity( + hass, + LIGHT_DOMAIN, + TYPE_HYPERION_PRIORITY_LIGHT, + TEST_PRIORITY_LIGHT_ENTITY_ID_1, + ) + await setup_test_config_entry(hass, hyperion_client=client) + + device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device({(DOMAIN, device_id)}) + assert device + assert device.config_entries == {TEST_CONFIG_ENTRY_ID} + assert device.identifiers == {(DOMAIN, device_id)} + assert device.manufacturer == HYPERION_MANUFACTURER_NAME + assert device.model == HYPERION_MODEL_NAME + assert device.name == TEST_INSTANCE_1["friendly_name"] + + entity_registry = await er.async_get_registry(hass) + entities_from_device = [ + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, device.id) + ] + assert TEST_PRIORITY_LIGHT_ENTITY_ID_1 in entities_from_device + assert TEST_ENTITY_ID_1 in entities_from_device + + +async def test_lights_can_be_enabled(hass: HomeAssistantType) -> None: + """Verify lights can be enabled.""" + client = create_mock_client() + await setup_test_config_entry(hass, hyperion_client=client) + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entry + assert entry.disabled + assert entry.disabled_by == "integration" + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert not entity_state + + with patch( + "homeassistant.components.hyperion.client.HyperionClient", + return_value=client, + ): + updated_entry = entity_registry.async_update_entity( + TEST_PRIORITY_LIGHT_ENTITY_ID_1, disabled_by=None + ) + assert not updated_entry.disabled + await hass.async_block_till_done() + + async_fire_time_changed( # type: ignore[no-untyped-call] + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1) + assert entity_state diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index 34030787e20..af1336bf0f8 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -1,27 +1,41 @@ """Tests for the Hyperion integration.""" +from datetime import timedelta from unittest.mock import AsyncMock, call, patch from hyperion.const import ( KEY_COMPONENT, KEY_COMPONENTID_ALL, - KEY_COMPONENTID_BLACKBORDER, - KEY_COMPONENTID_BOBLIGHTSERVER, - KEY_COMPONENTID_FORWARDER, - KEY_COMPONENTID_GRABBER, - KEY_COMPONENTID_LEDDEVICE, - KEY_COMPONENTID_SMOOTHING, - KEY_COMPONENTID_V4L, KEY_COMPONENTSTATE, KEY_STATE, ) -from homeassistant.components.hyperion.const import COMPONENT_TO_NAME +from homeassistant.components.hyperion import get_hyperion_device_id +from homeassistant.components.hyperion.const import ( + COMPONENT_TO_NAME, + DOMAIN, + HYPERION_MANUFACTURER_NAME, + HYPERION_MODEL_NAME, + TYPE_HYPERION_COMPONENT_SWITCH_BASE, +) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.util import slugify +from homeassistant.util import dt, slugify -from . import call_registered_callback, create_mock_client, setup_test_config_entry +from . import ( + TEST_CONFIG_ENTRY_ID, + TEST_INSTANCE, + TEST_INSTANCE_1, + TEST_SYSINFO_ID, + call_registered_callback, + create_mock_client, + register_test_entity, + setup_test_config_entry, +) + +from tests.common import async_fire_time_changed TEST_COMPONENTS = [ {"enabled": True, "name": "ALL"}, @@ -45,11 +59,13 @@ async def test_switch_turn_on_off(hass: HomeAssistantType) -> None: client.components = TEST_COMPONENTS # Setup component switch. - with patch( - "homeassistant.components.hyperion.switch.HyperionComponentSwitch.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - await setup_test_config_entry(hass, hyperion_client=client) + register_test_entity( + hass, + SWITCH_DOMAIN, + f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE}_all", + TEST_SWITCH_COMPONENT_ALL_ENTITY_ID, + ) + await setup_test_config_entry(hass, hyperion_client=client) # Verify switch is on (as per TEST_COMPONENTS above). entity_state = hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) @@ -111,28 +127,96 @@ async def test_switch_has_correct_entities(hass: HomeAssistantType) -> None: client.components = TEST_COMPONENTS # Setup component switch. - with patch( - "homeassistant.components.hyperion.switch.HyperionComponentSwitch.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - await setup_test_config_entry(hass, hyperion_client=client) - - entity_state = hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) - - for component in ( - KEY_COMPONENTID_ALL, - KEY_COMPONENTID_SMOOTHING, - KEY_COMPONENTID_BLACKBORDER, - KEY_COMPONENTID_FORWARDER, - KEY_COMPONENTID_BOBLIGHTSERVER, - KEY_COMPONENTID_GRABBER, - KEY_COMPONENTID_LEDDEVICE, - KEY_COMPONENTID_V4L, - ): - entity_id = ( - TEST_SWITCH_COMPONENT_BASE_ENTITY_ID - + "_" - + slugify(COMPONENT_TO_NAME[component]) + for component in TEST_COMPONENTS: + name = slugify(COMPONENT_TO_NAME[str(component["name"])]) + register_test_entity( + hass, + SWITCH_DOMAIN, + f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE}_{name}", + f"{TEST_SWITCH_COMPONENT_BASE_ENTITY_ID}_{name}", ) + await setup_test_config_entry(hass, hyperion_client=client) + + for component in TEST_COMPONENTS: + name = slugify(COMPONENT_TO_NAME[str(component["name"])]) + entity_id = TEST_SWITCH_COMPONENT_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: HomeAssistantType) -> None: + """Verify device information includes expected details.""" + client = create_mock_client() + client.components = TEST_COMPONENTS + + for component in TEST_COMPONENTS: + name = slugify(COMPONENT_TO_NAME[str(component["name"])]) + register_test_entity( + hass, + SWITCH_DOMAIN, + f"{TYPE_HYPERION_COMPONENT_SWITCH_BASE}_{name}", + f"{TEST_SWITCH_COMPONENT_BASE_ENTITY_ID}_{name}", + ) + await setup_test_config_entry(hass, hyperion_client=client) + assert hass.states.get(TEST_SWITCH_COMPONENT_ALL_ENTITY_ID) is not None + + device_identifer = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device({(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 = await er.async_get_registry(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_TO_NAME[str(component["name"])]) + entity_id = TEST_SWITCH_COMPONENT_BASE_ENTITY_ID + "_" + name + assert entity_id in entities_from_device + + +async def test_switches_can_be_enabled(hass: HomeAssistantType) -> None: + """Verify switches can be enabled.""" + client = create_mock_client() + client.components = TEST_COMPONENTS + await setup_test_config_entry(hass, hyperion_client=client) + + entity_registry = er.async_get(hass) + + for component in TEST_COMPONENTS: + name = slugify(COMPONENT_TO_NAME[str(component["name"])]) + entity_id = TEST_SWITCH_COMPONENT_BASE_ENTITY_ID + "_" + name + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == "integration" + entity_state = hass.states.get(entity_id) + assert not entity_state + + with patch( + "homeassistant.components.hyperion.client.HyperionClient", + return_value=client, + ): + updated_entry = entity_registry.async_update_entity( + entity_id, disabled_by=None + ) + assert not updated_entry.disabled + await hass.async_block_till_done() + + async_fire_time_changed( # type: ignore[no-untyped-call] + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_state = hass.states.get(entity_id) + assert entity_state