From ae692a003f47cca79afc828bade5f53be0d568cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 6 May 2021 01:41:32 +0200 Subject: [PATCH] Add support for Elgato Light Strip (#49988) Co-authored-by: Paulus Schoutsen --- homeassistant/components/elgato/__init__.py | 6 +- .../components/elgato/config_flow.py | 6 +- homeassistant/components/elgato/const.py | 2 +- homeassistant/components/elgato/light.py | 100 ++++++++++++---- homeassistant/components/elgato/manifest.json | 2 +- homeassistant/components/elgato/strings.json | 8 +- .../components/elgato/translations/en.json | 8 +- tests/components/elgato/__init__.py | 40 +++++-- tests/components/elgato/test_light.py | 113 ++++++++++++++++-- tests/fixtures/elgato/settings-color.json | 10 ++ tests/fixtures/elgato/settings.json | 8 ++ tests/fixtures/elgato/state-color.json | 11 ++ 12 files changed, 249 insertions(+), 65 deletions(-) create mode 100644 tests/fixtures/elgato/settings-color.json create mode 100644 tests/fixtures/elgato/settings.json create mode 100644 tests/fixtures/elgato/state-color.json diff --git a/homeassistant/components/elgato/__init__.py b/homeassistant/components/elgato/__init__.py index 1c83844debc..21b8de53c17 100644 --- a/homeassistant/components/elgato/__init__.py +++ b/homeassistant/components/elgato/__init__.py @@ -1,4 +1,4 @@ -"""Support for Elgato Key Lights.""" +"""Support for Elgato Lights.""" import logging from elgato import Elgato, ElgatoConnectionError @@ -16,7 +16,7 @@ PLATFORMS = [LIGHT_DOMAIN] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Elgato Key Light from a config entry.""" + """Set up Elgato Light from a config entry.""" session = async_get_clientsession(hass) elgato = Elgato( entry.data[CONF_HOST], @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload Elgato Key Light config entry.""" + """Unload Elgato Light config entry.""" # Unload entities for this entry/device. unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index bcfcc4dcb7f..6008ccbee77 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure the Elgato Key Light integration.""" +"""Config flow to configure the Elgato Light integration.""" from __future__ import annotations from typing import Any @@ -16,7 +16,7 @@ from .const import CONF_SERIAL_NUMBER, DOMAIN class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a Elgato Key Light config flow.""" + """Handle a Elgato Light config flow.""" VERSION = 1 @@ -91,7 +91,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): ) async def _get_elgato_serial_number(self, raise_on_progress: bool = True) -> None: - """Get device information from an Elgato Key Light device.""" + """Get device information from an Elgato Light device.""" session = async_get_clientsession(self.hass) elgato = Elgato( host=self.host, diff --git a/homeassistant/components/elgato/const.py b/homeassistant/components/elgato/const.py index 1cfb48390cf..03a52b7e305 100644 --- a/homeassistant/components/elgato/const.py +++ b/homeassistant/components/elgato/const.py @@ -1,4 +1,4 @@ -"""Constants for the Elgato Key Light integration.""" +"""Constants for the Elgato Light integration.""" # Integration domain DOMAIN = "elgato" diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index a81f5f214ba..abd1fae410e 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -1,17 +1,18 @@ -"""Support for LED lights.""" +"""Support for Elgato lights.""" from __future__ import annotations from datetime import timedelta import logging from typing import Any -from elgato import Elgato, ElgatoError, Info, State +from elgato import Elgato, ElgatoError, Info, Settings, State from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR_TEMP, + ATTR_HS_COLOR, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -42,10 +43,11 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Elgato Key Light based on a config entry.""" + """Set up Elgato Light based on a config entry.""" elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT] info = await elgato.info() - async_add_entities([ElgatoLight(elgato, info)], True) + settings = await elgato.settings() + async_add_entities([ElgatoLight(elgato, info, settings)], True) platform = async_get_current_platform() platform.async_register_entity_service( @@ -56,18 +58,25 @@ async def async_setup_entry( class ElgatoLight(LightEntity): - """Defines a Elgato Key Light.""" + """Defines an Elgato Light.""" - def __init__( - self, - elgato: Elgato, - info: Info, - ) -> None: - """Initialize Elgato Key Light.""" - self._info: Info = info + def __init__(self, elgato: Elgato, info: Info, settings: Settings) -> None: + """Initialize Elgato Light.""" + self._info = info + self._settings = settings self._state: State | None = None self.elgato = elgato + self._min_mired = 143 + self._max_mired = 344 + self._supported_color_modes = {COLOR_MODE_COLOR_TEMP} + + # Elgato Light supporting color, have a different temperature range + if settings.power_on_hue is not None: + self._supported_color_modes = {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} + self._min_mired = 153 + self._max_mired = 285 + @property def name(self) -> str: """Return the name of the entity.""" @@ -99,17 +108,38 @@ class ElgatoLight(LightEntity): @property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" - return 143 + return self._min_mired @property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" - return 344 + # Elgato lights with color capabilities have a different highest value + return self._max_mired @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + def supported_color_modes(self) -> set[str]: + """Flag supported color modes.""" + return self._supported_color_modes + + @property + def color_mode(self) -> str | None: + """Return the color mode of the light.""" + if self._state and self._state.hue is not None: + return COLOR_MODE_HS + + return COLOR_MODE_COLOR_TEMP + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value [float, float].""" + if ( + self._state is None + or self._state.hue is None + or self._state.saturation is None + ): + return None + + return (self._state.hue, self._state.saturation) @property def is_on(self) -> bool: @@ -122,22 +152,44 @@ class ElgatoLight(LightEntity): try: await self.elgato.light(on=False) except ElgatoError: - _LOGGER.error("An error occurred while updating the Elgato Key Light") + _LOGGER.error("An error occurred while updating the Elgato Light") self._state = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" temperature = kwargs.get(ATTR_COLOR_TEMP) + + hue = None + saturation = None + if ATTR_HS_COLOR in kwargs: + hue, saturation = kwargs[ATTR_HS_COLOR] + brightness = None if ATTR_BRIGHTNESS in kwargs: brightness = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) + # For Elgato lights supporting color mode, but in temperature mode; + # adjusting only brightness make them jump back to color mode. + # Resending temperature prevents that. + if ( + brightness + and ATTR_HS_COLOR not in kwargs + and ATTR_COLOR_TEMP not in kwargs + and COLOR_MODE_HS in self.supported_color_modes + and self.color_mode == COLOR_MODE_COLOR_TEMP + ): + temperature = self.color_temp + try: await self.elgato.light( - on=True, brightness=brightness, temperature=temperature + on=True, + brightness=brightness, + hue=hue, + saturation=saturation, + temperature=temperature, ) except ElgatoError: - _LOGGER.error("An error occurred while updating the Elgato Key Light") + _LOGGER.error("An error occurred while updating the Elgato Light") self._state = None async def async_update(self) -> None: @@ -149,12 +201,12 @@ class ElgatoLight(LightEntity): _LOGGER.info("Connection restored") except ElgatoError as err: meth = _LOGGER.error if self._state else _LOGGER.debug - meth("An error occurred while updating the Elgato Key Light: %s", err) + meth("An error occurred while updating the Elgato Light: %s", err) self._state = None @property def device_info(self) -> DeviceInfo: - """Return device information about this Elgato Key Light.""" + """Return device information about this Elgato Light.""" return { ATTR_IDENTIFIERS: {(DOMAIN, self._info.serial_number)}, ATTR_NAME: self._info.product_name, diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index ebc337c2925..dbb83f18995 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -1,6 +1,6 @@ { "domain": "elgato", - "name": "Elgato Key Light", + "name": "Elgato Light", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/elgato", "requirements": ["elgato==2.1.0"], diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 54c5f43a5da..577ed6c0206 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -1,17 +1,17 @@ { "config": { - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "Elgato Light: {serial_number}", "step": { "user": { - "description": "Set up your Elgato Key Light to integrate with Home Assistant.", + "description": "Set up your Elgato Light to integrate with Home Assistant.", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" } }, "zeroconf_confirm": { - "description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", - "title": "Discovered Elgato Key Light device" + "description": "Do you want to add the Elgato Light with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Elgato Light device" } }, "error": { diff --git a/homeassistant/components/elgato/translations/en.json b/homeassistant/components/elgato/translations/en.json index 7866631db42..fc75c20032c 100644 --- a/homeassistant/components/elgato/translations/en.json +++ b/homeassistant/components/elgato/translations/en.json @@ -7,18 +7,18 @@ "error": { "cannot_connect": "Failed to connect" }, - "flow_title": "Elgato Key Light: {serial_number}", + "flow_title": "Elgato Light: {serial_number}", "step": { "user": { "data": { "host": "Host", "port": "Port" }, - "description": "Set up your Elgato Key Light to integrate with Home Assistant." + "description": "Set up your Elgato Light to integrate with Home Assistant." }, "zeroconf_confirm": { - "description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", - "title": "Discovered Elgato Key Light device" + "description": "Do you want to add the Elgato Light with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Elgato Light device" } } } diff --git a/tests/components/elgato/__init__.py b/tests/components/elgato/__init__.py index ea63bc0c4d0..12df481d182 100644 --- a/tests/components/elgato/__init__.py +++ b/tests/components/elgato/__init__.py @@ -12,6 +12,8 @@ async def init_integration( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False, + color: bool = False, + mode_color: bool = False, ) -> MockConfigEntry: """Set up the Elgato Key Light integration in Home Assistant.""" aioclient_mock.get( @@ -20,24 +22,38 @@ async def init_integration( headers={"Content-Type": CONTENT_TYPE_JSON}, ) - aioclient_mock.put( - "http://127.0.0.1:9123/elgato/lights", - text=load_fixture("elgato/state.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - - aioclient_mock.get( - "http://127.0.0.1:9123/elgato/lights", - text=load_fixture("elgato/state.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - aioclient_mock.get( "http://127.0.0.2:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) + settings = "elgato/settings.json" + if color: + settings = "elgato/settings-color.json" + + aioclient_mock.get( + "http://127.0.0.1:9123/elgato/lights/settings", + text=load_fixture(settings), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + state = "elgato/state.json" + if mode_color: + state = "elgato/state-color.json" + + aioclient_mock.get( + "http://127.0.0.1:9123/elgato/lights", + text=load_fixture(state), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.put( + "http://127.0.0.1:9123/elgato/lights", + text=load_fixture("elgato/state.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + entry = MockConfigEntry( domain=DOMAIN, unique_id="CN11A1A00001", diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 38da5856f75..fbc926d318f 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -2,11 +2,19 @@ from unittest.mock import patch from elgato import ElgatoError +import pytest from homeassistant.components.elgato.const import DOMAIN, SERVICE_IDENTIFY from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, DOMAIN as LIGHT_DOMAIN, ) from homeassistant.const import ( @@ -24,10 +32,10 @@ from tests.components.elgato import init_integration from tests.test_util.aiohttp import AiohttpClientMocker -async def test_light_state( +async def test_light_state_temperature( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test the creation and values of the Elgato Key Lights.""" + """Test the creation and values of the Elgato Lights in temperature mode.""" await init_integration(hass, aioclient_mock) entity_registry = er.async_get(hass) @@ -37,6 +45,11 @@ async def test_light_state( assert state assert state.attributes.get(ATTR_BRIGHTNESS) == 54 assert state.attributes.get(ATTR_COLOR_TEMP) == 297 + assert state.attributes.get(ATTR_HS_COLOR) is None + assert state.attributes.get(ATTR_COLOR_MODE) == COLOR_MODE_COLOR_TEMP + assert state.attributes.get(ATTR_MIN_MIREDS) == 143 + assert state.attributes.get(ATTR_MAX_MIREDS) == 344 + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [COLOR_MODE_COLOR_TEMP] assert state.state == STATE_ON entry = entity_registry.async_get("light.frenck") @@ -44,13 +57,42 @@ async def test_light_state( assert entry.unique_id == "CN11A1A00001" -async def test_light_change_state( +async def test_light_state_color( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the creation and values of the Elgato Lights in temperature mode.""" + await init_integration(hass, aioclient_mock, color=True, mode_color=True) + + entity_registry = er.async_get(hass) + + # First segment of the strip + state = hass.states.get("light.frenck") + assert state + assert state.attributes.get(ATTR_BRIGHTNESS) == 128 + assert state.attributes.get(ATTR_COLOR_TEMP) is None + assert state.attributes.get(ATTR_HS_COLOR) == (358.0, 6.0) + assert state.attributes.get(ATTR_MIN_MIREDS) == 153 + assert state.attributes.get(ATTR_MAX_MIREDS) == 285 + assert state.attributes.get(ATTR_COLOR_MODE) == COLOR_MODE_HS + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + ] + assert state.state == STATE_ON + + entry = entity_registry.async_get("light.frenck") + assert entry + assert entry.unique_id == "CN11A1A00001" + + +async def test_light_change_state_temperature( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test the change of state of a Elgato Key Light device.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, color=True, mode_color=False) state = hass.states.get("light.frenck") + assert state assert state.state == STATE_ON with patch( @@ -69,12 +111,25 @@ async def test_light_change_state( ) await hass.async_block_till_done() assert len(mock_light.mock_calls) == 1 - mock_light.assert_called_with(on=True, brightness=100, temperature=100) + mock_light.assert_called_with( + on=True, brightness=100, temperature=100, hue=None, saturation=None + ) + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.frenck", + ATTR_BRIGHTNESS: 255, + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_light.mock_calls) == 2 + mock_light.assert_called_with( + on=True, brightness=100, temperature=297, hue=None, saturation=None + ) - with patch( - "homeassistant.components.elgato.light.Elgato.light", - return_value=mock_coro(), - ) as mock_light: await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -82,14 +137,46 @@ async def test_light_change_state( blocking=True, ) await hass.async_block_till_done() - assert len(mock_light.mock_calls) == 1 + assert len(mock_light.mock_calls) == 3 mock_light.assert_called_with(on=False) -async def test_light_unavailable( +async def test_light_change_state_color( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: - """Test error/unavailable handling of an Elgato Key Light.""" + """Test the color state state of a Elgato Light device.""" + await init_integration(hass, aioclient_mock, color=True) + + state = hass.states.get("light.frenck") + assert state + assert state.state == STATE_ON + + with patch( + "homeassistant.components.elgato.light.Elgato.light", + return_value=mock_coro(), + ) as mock_light: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.frenck", + ATTR_BRIGHTNESS: 255, + ATTR_HS_COLOR: (10.1, 20.2), + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_light.mock_calls) == 1 + mock_light.assert_called_with( + on=True, brightness=100, temperature=None, hue=10.1, saturation=20.2 + ) + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +async def test_light_unavailable( + service: str, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test error/unavailable handling of an Elgato Light.""" await init_integration(hass, aioclient_mock) with patch( "homeassistant.components.elgato.light.Elgato.light", @@ -100,7 +187,7 @@ async def test_light_unavailable( ): await hass.services.async_call( LIGHT_DOMAIN, - SERVICE_TURN_OFF, + service, {ATTR_ENTITY_ID: "light.frenck"}, blocking=True, ) diff --git a/tests/fixtures/elgato/settings-color.json b/tests/fixtures/elgato/settings-color.json new file mode 100644 index 00000000000..14a78c6fcaf --- /dev/null +++ b/tests/fixtures/elgato/settings-color.json @@ -0,0 +1,10 @@ +{ + "powerOnBehavior": 2, + "powerOnHue": 40.0, + "powerOnSaturation": 15.0, + "powerOnBrightness": 40, + "powerOnTemperature": 0, + "switchOnDurationMs": 150, + "switchOffDurationMs": 400, + "colorChangeDurationMs": 150 +} diff --git a/tests/fixtures/elgato/settings.json b/tests/fixtures/elgato/settings.json new file mode 100644 index 00000000000..bd918e24526 --- /dev/null +++ b/tests/fixtures/elgato/settings.json @@ -0,0 +1,8 @@ +{ + "powerOnBehavior": 1, + "powerOnBrightness": 20, + "powerOnTemperature": 213, + "switchOnDurationMs": 100, + "switchOffDurationMs": 300, + "colorChangeDurationMs": 100 +} diff --git a/tests/fixtures/elgato/state-color.json b/tests/fixtures/elgato/state-color.json new file mode 100644 index 00000000000..b49a6f6dd80 --- /dev/null +++ b/tests/fixtures/elgato/state-color.json @@ -0,0 +1,11 @@ +{ + "numberOfLights": 1, + "lights": [ + { + "on": 1, + "hue": 358.0, + "saturation": 6.0, + "brightness": 50 + } + ] +}