diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 5cf2bbb49417..310bd3a9752c 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -13,6 +13,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BUTTON, Platform.LIGHT, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/elgato/icons.json b/homeassistant/components/elgato/icons.json index 5ef09d94d764..e8e171f107ac 100644 --- a/homeassistant/components/elgato/icons.json +++ b/homeassistant/components/elgato/icons.json @@ -1,5 +1,13 @@ { "entity": { + "number": { + "power_on_brightness": { + "default": "mdi:brightness-percent" + }, + "power_on_temperature": { + "default": "mdi:thermometer" + } + }, "select": { "power_on_behavior": { "default": "mdi:power-settings" diff --git a/homeassistant/components/elgato/number.py b/homeassistant/components/elgato/number.py new file mode 100644 index 000000000000..f6eebb4667f5 --- /dev/null +++ b/homeassistant/components/elgato/number.py @@ -0,0 +1,130 @@ +"""Support for Elgato numbers.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, override + +from elgato import Elgato + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import ( + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator +from .entity import ElgatoEntity +from .helpers import color_temperature_range, elgato_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ElgatoNumberEntityDescription(NumberEntityDescription): + """Class describing Elgato number entities.""" + + has_fn: Callable[[ElgatoData], bool] = lambda _: True + range_fn: Callable[[ElgatoData], tuple[int, int]] | None = None + value_fn: Callable[[ElgatoData], float | None] + set_fn: Callable[[Elgato, float], Awaitable[Any]] + + +NUMBERS = [ + ElgatoNumberEntityDescription( + key="power_on_brightness", + translation_key="power_on_brightness", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + native_min_value=0, + native_max_value=100, + native_step=1, + value_fn=lambda x: x.settings.power_on_brightness, + set_fn=lambda client, value: client.power_on_behavior(brightness=int(value)), + ), + ElgatoNumberEntityDescription( + key="power_on_temperature", + translation_key="power_on_temperature", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.KELVIN, + # Narrows on a device that does color, exactly as the light does. + range_fn=color_temperature_range, + native_step=50, + has_fn=lambda x: x.settings.power_on_temperature is not None, + # A light set to power on to a color reports a zero, which is not a + # color temperature. The setting can be changed back, so the entity + # stays and goes unknown rather than disappearing. + value_fn=lambda x: ( + color_temperature_mired_to_kelvin(x.settings.power_on_temperature) + if x.settings.power_on_temperature + else None + ), + set_fn=lambda client, value: client.power_on_behavior( + temperature=color_temperature_kelvin_to_mired(value) + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ElgatoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Elgato numbers based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + ElgatoNumberEntity( + coordinator=coordinator, + description=description, + ) + for description in NUMBERS + if description.has_fn(coordinator.data) + ) + + +class ElgatoNumberEntity(ElgatoEntity, NumberEntity): + """Representation of an Elgato number.""" + + entity_description: ElgatoNumberEntityDescription + + def __init__( + self, + coordinator: ElgatoDataUpdateCoordinator, + description: ElgatoNumberEntityDescription, + ) -> None: + """Initiate Elgato number.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.data.info.serial_number}_{description.key}" + ) + + if description.range_fn is not None: + ( + self._attr_native_min_value, + self._attr_native_max_value, + ) = description.range_fn(coordinator.data) + + @property + @override + def native_value(self) -> float | None: + """Return the number value.""" + if (value := self.entity_description.value_fn(self.coordinator.data)) is None: + return None + + # A Kelvin value that survives the trip out does not always survive + # the trip back. Setting 6500 K stores 153 mireds, which reads as + # 6535 K, above a maximum that cannot then be set again. + return min(max(value, self.native_min_value), self.native_max_value) + + @elgato_exception_handler + @override + async def async_set_native_value(self, value: float) -> None: + """Change the number value.""" + await self.entity_description.set_fn(self.coordinator.client, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 1ac77067446a..ca63e4f3b7f7 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -36,6 +36,14 @@ } }, "entity": { + "number": { + "power_on_brightness": { + "name": "Power-on brightness" + }, + "power_on_temperature": { + "name": "Power-on color temperature" + } + }, "select": { "power_on_behavior": { "name": "Power-on behavior", diff --git a/tests/components/elgato/snapshots/test_number.ambr b/tests/components/elgato/snapshots/test_number.ambr new file mode 100644 index 000000000000..47b4ef7b5a84 --- /dev/null +++ b/tests/components/elgato/snapshots/test_number.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Power-on brightness', + : 100, + : 0, + : , + : 1, + : '%', + }), + 'context': , + 'entity_id': 'number.frenck_power_on_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100, + : 0, + : , + : 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frenck_power_on_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on brightness', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on brightness', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_on_brightness', + 'unique_id': 'CN11A1A00001_power_on_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[number.frenck_power_on_brightness-50-expected0-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, + }) +# --- +# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-key-light] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Frenck Power-on color temperature', + : 6993, + : 2900, + : , + : 50, + : , + }), + 'context': , + 'entity_id': 'number.frenck_power_on_color_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4694', + }) +# --- +# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-key-light].1 + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 6993, + : 2900, + : , + : 50, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.frenck_power_on_color_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power-on color temperature', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Power-on color temperature', + 'platform': 'elgato', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_on_temperature', + 'unique_id': 'CN11A1A00001_power_on_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-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_number.py b/tests/components/elgato/test_number.py new file mode 100644 index 000000000000..d6b787fa712b --- /dev/null +++ b/tests/components/elgato/test_number.py @@ -0,0 +1,159 @@ +"""Tests for the Elgato number platform.""" + +from unittest.mock import MagicMock + +from elgato import ElgatoError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + +# Each test says which device it wants, and when the integration is set up. + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixtures", ["key-light"]) +@pytest.mark.parametrize( + ("entity_id", "value", "expected"), + [ + ("number.frenck_power_on_brightness", 50, {"brightness": 50}), + ("number.frenck_power_on_color_temperature", 5000, {"temperature": 200}), + ], +) +async def test_numbers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_elgato: MagicMock, + snapshot: SnapshotAssertion, + entity_id: str, + value: float, + expected: dict[str, int], +) -> None: + """Test the Elgato numbers.""" + 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( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 1 + mock_elgato.power_on_behavior.assert_called_once_with(**expected) + + 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( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + assert len(mock_elgato.power_on_behavior.mock_calls) == 2 + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("device_fixtures", ["light-strip"]) +async def test_power_on_temperature_unknown(hass: HomeAssistant) -> None: + """Test a light that powers on to a color instead of a temperature. + + It reports a power-on temperature of zero, which is not a temperature. + The entity still exists, because whether the device reports the field is + a property of the device, while what it currently holds is not. + """ + assert hass.states.get("number.frenck_power_on_brightness") + + assert (state := hass.states.get("number.frenck_power_on_color_temperature")) + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + ("device_fixtures", "expected_range"), + [ + ("key-light", (2900, 6993)), + ("light-strip", (3500, 6500)), + ], +) +async def test_power_on_temperature_range( + hass: HomeAssistant, + expected_range: tuple[int, int], +) -> None: + """Test the number stays inside what the device can actually do. + + A light that does color reaches less far at either end, and the number + has to agree with the light entity about that. + """ + minimum, maximum = expected_range + + assert (state := hass.states.get("number.frenck_power_on_color_temperature")) + assert state.attributes["min"] == minimum + assert state.attributes["max"] == maximum + + +@pytest.mark.parametrize("device_fixtures", ["light-strip"]) +async def test_power_on_temperature_at_the_edge( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, +) -> None: + """Test the reported value stays inside the range that can be set. + + Setting the maximum of 6500 K stores 153 mireds, which converts back to + 6535 K. Reporting that would put the entity above a maximum the user + cannot submit again. + """ + mock_elgato.settings.return_value.power_on_temperature = 153 + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get("number.frenck_power_on_color_temperature")) + assert state.state == "6500" + + +@pytest.mark.parametrize("device_fixtures", ["light-strip"]) +async def test_power_on_temperature_absent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_elgato: MagicMock, +) -> None: + """Test a device that does not report a power-on temperature at all. + + Reporting the field is what the entity hangs off, so a device without it + gets no entity, while the brightness one is unaffected. + """ + mock_elgato.settings.return_value.power_on_temperature = None + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("number.frenck_power_on_brightness") + assert not hass.states.get("number.frenck_power_on_color_temperature")