diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index c11f70654345..5cf2bbb49417 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -10,7 +10,13 @@ from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.BUTTON, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.LIGHT, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/elgato/icons.json b/homeassistant/components/elgato/icons.json index d2c286594c7b..5ef09d94d764 100644 --- a/homeassistant/components/elgato/icons.json +++ b/homeassistant/components/elgato/icons.json @@ -1,5 +1,10 @@ { "entity": { + "select": { + "power_on_behavior": { + "default": "mdi:power-settings" + } + }, "switch": { "bypass": { "default": "mdi:battery-off-outline" diff --git a/homeassistant/components/elgato/select.py b/homeassistant/components/elgato/select.py new file mode 100644 index 000000000000..fc7e7b0cf8d3 --- /dev/null +++ b/homeassistant/components/elgato/select.py @@ -0,0 +1,100 @@ +"""Support for Elgato select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from elgato import Elgato, PowerOnBehavior + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator +from .entity import ElgatoEntity +from .helpers import elgato_exception_handler + +PARALLEL_UPDATES = 1 + +# The device also has a value 0, which it reports when it has no opinion yet. +# It cannot be selected, so it is not an option, and it leaves the entity +# unknown until the behavior is actually set. +POWER_ON_BEHAVIORS = { + PowerOnBehavior.RESTORE_LAST: "restore_last", + PowerOnBehavior.USE_DEFAULTS: "use_defaults", +} +POWER_ON_BEHAVIOR_OPTIONS = {value: key for key, value in POWER_ON_BEHAVIORS.items()} + + +@dataclass(frozen=True, kw_only=True) +class ElgatoSelectEntityDescription(SelectEntityDescription): + """Class describing Elgato select entities.""" + + has_fn: Callable[[ElgatoData], bool] = lambda _: True + current_fn: Callable[[ElgatoData], str | None] + select_fn: Callable[[Elgato, str], Awaitable[Any]] + + +SELECTS = [ + ElgatoSelectEntityDescription( + key="power_on_behavior", + translation_key="power_on_behavior", + entity_category=EntityCategory.CONFIG, + options=list(POWER_ON_BEHAVIORS.values()), + current_fn=lambda x: POWER_ON_BEHAVIORS.get(x.settings.power_on_behavior), + select_fn=lambda client, option: client.power_on_behavior( + behavior=POWER_ON_BEHAVIOR_OPTIONS[option] + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ElgatoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Elgato select entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + ElgatoSelectEntity( + coordinator=coordinator, + description=description, + ) + for description in SELECTS + if description.has_fn(coordinator.data) + ) + + +class ElgatoSelectEntity(ElgatoEntity, SelectEntity): + """Representation of an Elgato select entity.""" + + entity_description: ElgatoSelectEntityDescription + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + description: ElgatoSelectEntityDescription, + ) -> None: + """Initiate Elgato select entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.info.serial_number}_{description.key}" + ) + + @property + @override + def current_option(self) -> str | None: + """Return the selected option.""" + return self.entity_description.current_fn(self.coordinator.data) + + @elgato_exception_handler + @override + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.client, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index dcfeb23d9acc..1ac77067446a 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -36,6 +36,15 @@ } }, "entity": { + "select": { + "power_on_behavior": { + "name": "Power-on behavior", + "state": { + "restore_last": "Restore last state", + "use_defaults": "Use defaults" + } + } + }, "sensor": { "charge_power": { "name": "Charging power" diff --git a/tests/components/elgato/snapshots/test_select.ambr b/tests/components/elgato/snapshots/test_select.ambr new file mode 100644 index 000000000000..923b8ba141f1 --- /dev/null +++ b/tests/components/elgato/snapshots/test_select.ambr @@ -0,0 +1,94 @@ +# serializer version: 1 +# name: test_power_on_behavior[key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Power-on behavior', + : list([ + 'restore_last', + 'use_defaults', + ]), + }), + 'context': , + 'entity_id': 'select.frenck_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'restore_last', + }) +# --- +# name: test_power_on_behavior[key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'restore_last', + 'use_defaults', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.frenck_power_on_behavior', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on behavior', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on behavior', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_on_behavior', + 'unique_id': 'CN11A1A00001_power_on_behavior', + 'unit_of_measurement': None, + }) +# --- +# name: test_power_on_behavior[key-light].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '53', + 'id': , + 'identifiers': set({ + tuple( + 'elgato', + 'CN11A1A00001', + ), + }), + 'labels': set({ + }), + 'manufacturer': 'Elgato', + 'model': 'Elgato Key Light', + 'model_id': None, + 'name': 'Frenck', + 'name_by_user': None, + 'serial_number': 'CN11A1A00001', + 'sw_version': '1.0.3 (192)', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/elgato/test_select.py b/tests/components/elgato/test_select.py new file mode 100644 index 000000000000..2b317595f652 --- /dev/null +++ b/tests/components/elgato/test_select.py @@ -0,0 +1,70 @@ +"""Tests for the Elgato select platform.""" + +from unittest.mock import MagicMock + +from elgato import ElgatoError, PowerOnBehavior +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.parametrize("device_fixtures", ["key-light"]), + pytest.mark.usefixtures("device_fixtures", "init_integration"), +] + + +async def test_power_on_behavior( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_elgato: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the Elgato power-on behavior select.""" + entity_id = "select.frenck_power_on_behavior" + + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entry := entity_registry.async_get(entity_id)) + assert entry == snapshot + + assert entry.device_id + assert (device_entry := device_registry.async_get(entry.device_id)) + assert device_entry == snapshot + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "use_defaults"}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 1 + mock_elgato.power_on_behavior.assert_called_once_with( + behavior=PowerOnBehavior.USE_DEFAULTS + ) + + mock_elgato.power_on_behavior.side_effect = ElgatoError + + with pytest.raises( + HomeAssistantError, + match="An unknown error occurred while communicating with the Elgato device", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "restore_last"}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 2