Add Elgato power-on number entities (#180720)

This commit is contained in:
Franck Nijhof
2026-08-30 12:14:02 +02:00
committed by GitHub
parent ea7c98a060
commit 21f7d4dcb7
6 changed files with 495 additions and 0 deletions
@@ -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,
@@ -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"
+130
View File
@@ -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()
@@ -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",
@@ -0,0 +1,189 @@
# serializer version: 1
# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Frenck Power-on brightness',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: '%',
}),
'context': <ANY>,
'entity_id': 'number.frenck_power_on_brightness',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20',
})
# ---
# name: test_numbers[number.frenck_power_on_brightness-50-expected0-key-light].1
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 100,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 0,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.frenck_power_on_brightness',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '53',
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Frenck Power-on color temperature',
<NumberEntityCapabilityAttribute.MAX: 'max'>: 6993,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 2900,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 50,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.KELVIN: 'K'>,
}),
'context': <ANY>,
'entity_id': 'number.frenck_power_on_color_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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({
<NumberEntityCapabilityAttribute.MAX: 'max'>: 6993,
<NumberEntityCapabilityAttribute.MIN: 'min'>: 2900,
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
<NumberEntityCapabilityAttribute.STEP: 'step'>: 50,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.frenck_power_on_color_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <UnitOfTemperature.KELVIN: 'K'>,
})
# ---
# name: test_numbers[number.frenck_power_on_color_temperature-5000-expected1-key-light].2
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '53',
'id': <ANY>,
'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,
})
# ---
+159
View File
@@ -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")