Add support for Elgato Light Strip (#49988)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
pull/50141/head
Franck Nijhof 2021-05-06 01:41:32 +02:00 committed by GitHub
parent e4ef06d6b1
commit ae692a003f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 249 additions and 65 deletions

View File

@ -1,4 +1,4 @@
"""Support for Elgato Key Lights.""" """Support for Elgato Lights."""
import logging import logging
from elgato import Elgato, ElgatoConnectionError from elgato import Elgato, ElgatoConnectionError
@ -16,7 +16,7 @@ PLATFORMS = [LIGHT_DOMAIN]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) session = async_get_clientsession(hass)
elgato = Elgato( elgato = Elgato(
entry.data[CONF_HOST], 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: 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 entities for this entry/device.
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:

View File

@ -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 __future__ import annotations
from typing import Any from typing import Any
@ -16,7 +16,7 @@ from .const import CONF_SERIAL_NUMBER, DOMAIN
class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Elgato Key Light config flow.""" """Handle a Elgato Light config flow."""
VERSION = 1 VERSION = 1
@ -91,7 +91,7 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN):
) )
async def _get_elgato_serial_number(self, raise_on_progress: bool = True) -> None: 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) session = async_get_clientsession(self.hass)
elgato = Elgato( elgato = Elgato(
host=self.host, host=self.host,

View File

@ -1,4 +1,4 @@
"""Constants for the Elgato Key Light integration.""" """Constants for the Elgato Light integration."""
# Integration domain # Integration domain
DOMAIN = "elgato" DOMAIN = "elgato"

View File

@ -1,17 +1,18 @@
"""Support for LED lights.""" """Support for Elgato lights."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
from elgato import Elgato, ElgatoError, Info, State from elgato import Elgato, ElgatoError, Info, Settings, State
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP, ATTR_COLOR_TEMP,
SUPPORT_BRIGHTNESS, ATTR_HS_COLOR,
SUPPORT_COLOR_TEMP, COLOR_MODE_COLOR_TEMP,
COLOR_MODE_HS,
LightEntity, LightEntity,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -42,10 +43,11 @@ async def async_setup_entry(
entry: ConfigEntry, entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> 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] elgato: Elgato = hass.data[DOMAIN][entry.entry_id][DATA_ELGATO_CLIENT]
info = await elgato.info() 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_get_current_platform()
platform.async_register_entity_service( platform.async_register_entity_service(
@ -56,18 +58,25 @@ async def async_setup_entry(
class ElgatoLight(LightEntity): class ElgatoLight(LightEntity):
"""Defines a Elgato Key Light.""" """Defines an Elgato Light."""
def __init__( def __init__(self, elgato: Elgato, info: Info, settings: Settings) -> None:
self, """Initialize Elgato Light."""
elgato: Elgato, self._info = info
info: Info, self._settings = settings
) -> None:
"""Initialize Elgato Key Light."""
self._info: Info = info
self._state: State | None = None self._state: State | None = None
self.elgato = elgato 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 @property
def name(self) -> str: def name(self) -> str:
"""Return the name of the entity.""" """Return the name of the entity."""
@ -99,17 +108,38 @@ class ElgatoLight(LightEntity):
@property @property
def min_mireds(self) -> int: def min_mireds(self) -> int:
"""Return the coldest color_temp that this light supports.""" """Return the coldest color_temp that this light supports."""
return 143 return self._min_mired
@property @property
def max_mireds(self) -> int: def max_mireds(self) -> int:
"""Return the warmest color_temp that this light supports.""" """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 @property
def supported_features(self) -> int: def supported_color_modes(self) -> set[str]:
"""Flag supported features.""" """Flag supported color modes."""
return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP 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 @property
def is_on(self) -> bool: def is_on(self) -> bool:
@ -122,22 +152,44 @@ class ElgatoLight(LightEntity):
try: try:
await self.elgato.light(on=False) await self.elgato.light(on=False)
except ElgatoError: 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 self._state = None
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light.""" """Turn on the light."""
temperature = kwargs.get(ATTR_COLOR_TEMP) temperature = kwargs.get(ATTR_COLOR_TEMP)
hue = None
saturation = None
if ATTR_HS_COLOR in kwargs:
hue, saturation = kwargs[ATTR_HS_COLOR]
brightness = None brightness = None
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
brightness = round((kwargs[ATTR_BRIGHTNESS] / 255) * 100) 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: try:
await self.elgato.light( await self.elgato.light(
on=True, brightness=brightness, temperature=temperature on=True,
brightness=brightness,
hue=hue,
saturation=saturation,
temperature=temperature,
) )
except ElgatoError: 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 self._state = None
async def async_update(self) -> None: async def async_update(self) -> None:
@ -149,12 +201,12 @@ class ElgatoLight(LightEntity):
_LOGGER.info("Connection restored") _LOGGER.info("Connection restored")
except ElgatoError as err: except ElgatoError as err:
meth = _LOGGER.error if self._state else _LOGGER.debug 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 self._state = None
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device information about this Elgato Key Light.""" """Return device information about this Elgato Light."""
return { return {
ATTR_IDENTIFIERS: {(DOMAIN, self._info.serial_number)}, ATTR_IDENTIFIERS: {(DOMAIN, self._info.serial_number)},
ATTR_NAME: self._info.product_name, ATTR_NAME: self._info.product_name,

View File

@ -1,6 +1,6 @@
{ {
"domain": "elgato", "domain": "elgato",
"name": "Elgato Key Light", "name": "Elgato Light",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/elgato", "documentation": "https://www.home-assistant.io/integrations/elgato",
"requirements": ["elgato==2.1.0"], "requirements": ["elgato==2.1.0"],

View File

@ -1,17 +1,17 @@
{ {
"config": { "config": {
"flow_title": "Elgato Key Light: {serial_number}", "flow_title": "Elgato Light: {serial_number}",
"step": { "step": {
"user": { "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": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
} }
}, },
"zeroconf_confirm": { "zeroconf_confirm": {
"description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", "description": "Do you want to add the Elgato Light with serial number `{serial_number}` to Home Assistant?",
"title": "Discovered Elgato Key Light device" "title": "Discovered Elgato Light device"
} }
}, },
"error": { "error": {

View File

@ -7,18 +7,18 @@
"error": { "error": {
"cannot_connect": "Failed to connect" "cannot_connect": "Failed to connect"
}, },
"flow_title": "Elgato Key Light: {serial_number}", "flow_title": "Elgato Light: {serial_number}",
"step": { "step": {
"user": { "user": {
"data": { "data": {
"host": "Host", "host": "Host",
"port": "Port" "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": { "zeroconf_confirm": {
"description": "Do you want to add the Elgato Key Light with serial number `{serial_number}` to Home Assistant?", "description": "Do you want to add the Elgato Light with serial number `{serial_number}` to Home Assistant?",
"title": "Discovered Elgato Key Light device" "title": "Discovered Elgato Light device"
} }
} }
} }

View File

@ -12,6 +12,8 @@ async def init_integration(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
skip_setup: bool = False, skip_setup: bool = False,
color: bool = False,
mode_color: bool = False,
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Set up the Elgato Key Light integration in Home Assistant.""" """Set up the Elgato Key Light integration in Home Assistant."""
aioclient_mock.get( aioclient_mock.get(
@ -20,24 +22,38 @@ async def init_integration(
headers={"Content-Type": CONTENT_TYPE_JSON}, 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( aioclient_mock.get(
"http://127.0.0.2:9123/elgato/accessory-info", "http://127.0.0.2:9123/elgato/accessory-info",
text=load_fixture("elgato/info.json"), text=load_fixture("elgato/info.json"),
headers={"Content-Type": CONTENT_TYPE_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( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id="CN11A1A00001", unique_id="CN11A1A00001",

View File

@ -2,11 +2,19 @@
from unittest.mock import patch from unittest.mock import patch
from elgato import ElgatoError from elgato import ElgatoError
import pytest
from homeassistant.components.elgato.const import DOMAIN, SERVICE_IDENTIFY from homeassistant.components.elgato.const import DOMAIN, SERVICE_IDENTIFY
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_MODE,
ATTR_COLOR_TEMP, 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, DOMAIN as LIGHT_DOMAIN,
) )
from homeassistant.const import ( from homeassistant.const import (
@ -24,10 +32,10 @@ from tests.components.elgato import init_integration
from tests.test_util.aiohttp import AiohttpClientMocker from tests.test_util.aiohttp import AiohttpClientMocker
async def test_light_state( async def test_light_state_temperature(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: ) -> 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) await init_integration(hass, aioclient_mock)
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
@ -37,6 +45,11 @@ async def test_light_state(
assert state assert state
assert state.attributes.get(ATTR_BRIGHTNESS) == 54 assert state.attributes.get(ATTR_BRIGHTNESS) == 54
assert state.attributes.get(ATTR_COLOR_TEMP) == 297 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 assert state.state == STATE_ON
entry = entity_registry.async_get("light.frenck") entry = entity_registry.async_get("light.frenck")
@ -44,13 +57,42 @@ async def test_light_state(
assert entry.unique_id == "CN11A1A00001" 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 hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: ) -> None:
"""Test the change of state of a Elgato Key Light device.""" """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") state = hass.states.get("light.frenck")
assert state
assert state.state == STATE_ON assert state.state == STATE_ON
with patch( with patch(
@ -69,12 +111,25 @@ async def test_light_change_state(
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_light.mock_calls) == 1 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( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_OFF, SERVICE_TURN_OFF,
@ -82,14 +137,46 @@ async def test_light_change_state(
blocking=True, blocking=True,
) )
await hass.async_block_till_done() 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) mock_light.assert_called_with(on=False)
async def test_light_unavailable( async def test_light_change_state_color(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None: ) -> 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) await init_integration(hass, aioclient_mock)
with patch( with patch(
"homeassistant.components.elgato.light.Elgato.light", "homeassistant.components.elgato.light.Elgato.light",
@ -100,7 +187,7 @@ async def test_light_unavailable(
): ):
await hass.services.async_call( await hass.services.async_call(
LIGHT_DOMAIN, LIGHT_DOMAIN,
SERVICE_TURN_OFF, service,
{ATTR_ENTITY_ID: "light.frenck"}, {ATTR_ENTITY_ID: "light.frenck"},
blocking=True, blocking=True,
) )

View File

@ -0,0 +1,10 @@
{
"powerOnBehavior": 2,
"powerOnHue": 40.0,
"powerOnSaturation": 15.0,
"powerOnBrightness": 40,
"powerOnTemperature": 0,
"switchOnDurationMs": 150,
"switchOffDurationMs": 400,
"colorChangeDurationMs": 150
}

8
tests/fixtures/elgato/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"powerOnBehavior": 1,
"powerOnBrightness": 20,
"powerOnTemperature": 213,
"switchOnDurationMs": 100,
"switchOffDurationMs": 300,
"colorChangeDurationMs": 100
}

11
tests/fixtures/elgato/state-color.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"numberOfLights": 1,
"lights": [
{
"on": 1,
"hue": 358.0,
"saturation": 6.0,
"brightness": 50
}
]
}