diff --git a/CODEOWNERS b/CODEOWNERS index fe3af4c1ee6..c6deb8e9f8f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -242,6 +242,7 @@ homeassistant/components/keyboard_remote/* @bendavid homeassistant/components/knx/* @Julius2342 @farmio @marvin-w homeassistant/components/kodi/* @OnFreund @cgtobi homeassistant/components/konnected/* @heythisisnate @kit-klein +homeassistant/components/kulersky/* @emlove homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py new file mode 100644 index 00000000000..ff984e2c0d3 --- /dev/null +++ b/homeassistant/components/kulersky/__init__.py @@ -0,0 +1,44 @@ +"""Kuler Sky lights integration.""" +import asyncio + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Kuler Sky component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Kuler Sky from a config entry.""" + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/kulersky/config_flow.py b/homeassistant/components/kulersky/config_flow.py new file mode 100644 index 00000000000..2b22fcdbd31 --- /dev/null +++ b/homeassistant/components/kulersky/config_flow.py @@ -0,0 +1,29 @@ +"""Config flow for Kuler Sky.""" +import logging + +import pykulersky + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass) -> bool: + """Return if there are devices that can be discovered.""" + # Check if there are any devices that can be discovered in the network. + try: + devices = await hass.async_add_executor_job( + pykulersky.discover_bluetooth_devices + ) + except pykulersky.PykulerskyException as exc: + _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc) + return False + return len(devices) > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "Kuler Sky", _async_has_devices, config_entries.CONN_CLASS_UNKNOWN +) diff --git a/homeassistant/components/kulersky/const.py b/homeassistant/components/kulersky/const.py new file mode 100644 index 00000000000..ae1e7a435dc --- /dev/null +++ b/homeassistant/components/kulersky/const.py @@ -0,0 +1,2 @@ +"""Constants for the Kuler Sky integration.""" +DOMAIN = "kulersky" diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py new file mode 100644 index 00000000000..4c17d1bcba3 --- /dev/null +++ b/homeassistant/components/kulersky/light.py @@ -0,0 +1,210 @@ +"""Kuler Sky light platform.""" +import asyncio +from datetime import timedelta +import logging +from typing import Callable, List + +import pykulersky + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_WHITE_VALUE, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_WHITE_VALUE, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +import homeassistant.util.color as color_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_KULERSKY = SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_WHITE_VALUE + +DISCOVERY_INTERVAL = timedelta(seconds=60) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up Kuler sky light devices.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + if "devices" not in hass.data[DOMAIN]: + hass.data[DOMAIN]["devices"] = set() + if "discovery" not in hass.data[DOMAIN]: + hass.data[DOMAIN]["discovery"] = asyncio.Lock() + + async def discover(*args): + """Attempt to discover new lights.""" + # Since discovery needs to connect to all discovered bluetooth devices, and + # only rules out devices after a timeout, it can potentially take a long + # time. If there's already a discovery running, just skip this poll. + if hass.data[DOMAIN]["discovery"].locked(): + return + + async with hass.data[DOMAIN]["discovery"]: + bluetooth_devices = await hass.async_add_executor_job( + pykulersky.discover_bluetooth_devices + ) + + # Filter out already connected lights + new_devices = [ + device + for device in bluetooth_devices + if device["address"] not in hass.data[DOMAIN]["devices"] + ] + + for device in new_devices: + light = pykulersky.Light(device["address"], device["name"]) + try: + # Attempt to connect to this light and read the color. If the + # connection fails, either this is not a Kuler Sky light, or + # it's bluetooth connection is currently locked by another + # device. If the vendor's app is connected to the light when + # home assistant tries to connect, this connection will fail. + await hass.async_add_executor_job(light.connect) + await hass.async_add_executor_job(light.get_color) + except pykulersky.PykulerskyException: + continue + # The light has successfully connected + hass.data[DOMAIN]["devices"].add(device["address"]) + async_add_entities([KulerskyLight(light)], update_before_add=True) + + # Start initial discovery + hass.async_add_job(discover) + + # Perform recurring discovery of new devices + async_track_time_interval(hass, discover, DISCOVERY_INTERVAL) + + +class KulerskyLight(LightEntity): + """Representation of an Kuler Sky Light.""" + + def __init__(self, light: pykulersky.Light): + """Initialize a Kuler Sky light.""" + self._light = light + self._hs_color = None + self._brightness = None + self._white_value = None + self._available = True + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.async_on_remove( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.disconnect) + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + await self.hass.async_add_executor_job(self.disconnect) + + def disconnect(self, *args) -> None: + """Disconnect the underlying device.""" + self._light.disconnect() + + @property + def name(self): + """Return the display name of this light.""" + return self._light.name + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._light.address + + @property + def device_info(self): + """Device info for this light.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Brightech", + } + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_KULERSKY + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def hs_color(self): + """Return the hs color.""" + return self._hs_color + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white_value + + @property + def is_on(self): + """Return true if light is on.""" + return self._brightness > 0 or self._white_value > 0 + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + default_hs = (0, 0) if self._hs_color is None else self._hs_color + hue_sat = kwargs.get(ATTR_HS_COLOR, default_hs) + + default_brightness = 0 if self._brightness is None else self._brightness + brightness = kwargs.get(ATTR_BRIGHTNESS, default_brightness) + + default_white_value = 255 if self._white_value is None else self._white_value + white_value = kwargs.get(ATTR_WHITE_VALUE, default_white_value) + + if brightness == 0 and white_value == 0 and not kwargs: + # If the light would be off, and no additional parameters were + # passed, just turn the light on full brightness. + brightness = 255 + white_value = 255 + + rgb = color_util.color_hsv_to_RGB(*hue_sat, brightness / 255 * 100) + + self._light.set_color(*rgb, white_value) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.set_color(0, 0, 0, 0) + + def update(self): + """Fetch new state data for this light.""" + try: + if not self._light.connected: + self._light.connect() + # pylint: disable=invalid-name + r, g, b, w = self._light.get_color() + except pykulersky.PykulerskyException as exc: + if self._available: + _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) + self._available = False + return + if not self._available: + _LOGGER.info("Reconnected to %s", self.entity_id) + self._available = True + + hsv = color_util.color_RGB_to_hsv(r, g, b) + self._hs_color = hsv[:2] + self._brightness = int(round((hsv[2] / 100) * 255)) + self._white_value = w diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json new file mode 100644 index 00000000000..4f445e4fc18 --- /dev/null +++ b/homeassistant/components/kulersky/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "kulersky", + "name": "Kuler Sky", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/kulersky", + "requirements": [ + "pykulersky==0.4.0" + ], + "codeowners": [ + "@emlove" + ] +} diff --git a/homeassistant/components/kulersky/strings.json b/homeassistant/components/kulersky/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/kulersky/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/kulersky/translations/en.json b/homeassistant/components/kulersky/translations/en.json new file mode 100644 index 00000000000..f05becffed3 --- /dev/null +++ b/homeassistant/components/kulersky/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to start set up?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a8e871aa02e..833f11190b6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -108,6 +108,7 @@ FLOWS = [ "juicenet", "kodi", "konnected", + "kulersky", "life360", "lifx", "local_ip", diff --git a/requirements_all.txt b/requirements_all.txt index cb93fc3e7c9..725bc5f0c09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1475,6 +1475,9 @@ pykira==0.1.1 # homeassistant.components.kodi pykodi==0.2.1 +# homeassistant.components.kulersky +pykulersky==0.4.0 + # homeassistant.components.kwb pykwb==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df7cbdafc63..c3f8ed3c3b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -745,6 +745,9 @@ pykira==0.1.1 # homeassistant.components.kodi pykodi==0.2.1 +# homeassistant.components.kulersky +pykulersky==0.4.0 + # homeassistant.components.lastfm pylast==4.0.0 diff --git a/tests/components/kulersky/__init__.py b/tests/components/kulersky/__init__.py new file mode 100644 index 00000000000..2b723b28fbd --- /dev/null +++ b/tests/components/kulersky/__init__.py @@ -0,0 +1 @@ +"""Tests for the Kuler Sky integration.""" diff --git a/tests/components/kulersky/test_config_flow.py b/tests/components/kulersky/test_config_flow.py new file mode 100644 index 00000000000..59e3188fd7e --- /dev/null +++ b/tests/components/kulersky/test_config_flow.py @@ -0,0 +1,104 @@ +"""Test the Kuler Sky config flow.""" +import pykulersky + +from homeassistant import config_entries, setup +from homeassistant.components.kulersky.config_flow import DOMAIN + +from tests.async_mock import patch + + +async def test_flow_success(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.kulersky.config_flow.pykulersky.discover_bluetooth_devices", + return_value=[ + { + "address": "AA:BB:CC:11:22:33", + "name": "Bedroom", + } + ], + ), patch( + "homeassistant.components.kulersky.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.kulersky.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Kuler Sky" + assert result2["data"] == {} + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_no_devices_found(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.kulersky.config_flow.pykulersky.discover_bluetooth_devices", + return_value=[], + ), patch( + "homeassistant.components.kulersky.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.kulersky.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_flow_exceptions_caught(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.kulersky.config_flow.pykulersky.discover_bluetooth_devices", + side_effect=pykulersky.PykulerskyException("TEST"), + ), patch( + "homeassistant.components.kulersky.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.kulersky.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "no_devices_found" + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py new file mode 100644 index 00000000000..1b2472d7d7f --- /dev/null +++ b/tests/components/kulersky/test_light.py @@ -0,0 +1,315 @@ +"""Test the Kuler Sky lights.""" +import asyncio + +import pykulersky +import pytest + +from homeassistant import setup +from homeassistant.components.kulersky.light import DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_WHITE_VALUE, + ATTR_XY_COLOR, + SCAN_INTERVAL, + SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_WHITE_VALUE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +import homeassistant.util.dt as dt_util + +from tests.async_mock import MagicMock, patch +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +async def mock_entry(hass): + """Create a mock light entity.""" + return MockConfigEntry(domain=DOMAIN) + + +@pytest.fixture +async def mock_light(hass, mock_entry): + """Create a mock light entity.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + light = MagicMock(spec=pykulersky.Light) + light.address = "AA:BB:CC:11:22:33" + light.name = "Bedroom" + light.connected = False + with patch( + "homeassistant.components.kulersky.light.pykulersky.discover_bluetooth_devices", + return_value=[ + { + "address": "AA:BB:CC:11:22:33", + "name": "Bedroom", + } + ], + ): + with patch( + "homeassistant.components.kulersky.light.pykulersky.Light" + ) as mockdevice, patch.object(light, "connect") as mock_connect, patch.object( + light, "get_color", return_value=(0, 0, 0, 0) + ): + mockdevice.return_value = light + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_connect.called + light.connected = True + + yield light + + +async def test_init(hass, mock_light): + """Test platform setup.""" + state = hass.states.get("light.bedroom") + assert state.state == STATE_OFF + assert state.attributes == { + ATTR_FRIENDLY_NAME: "Bedroom", + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS + | SUPPORT_COLOR + | SUPPORT_WHITE_VALUE, + } + + with patch.object(hass.loop, "stop"), patch.object( + mock_light, "disconnect" + ) as mock_disconnect: + await hass.async_stop() + await hass.async_block_till_done() + + assert mock_disconnect.called + + +async def test_discovery_lock(hass, mock_entry): + """Test discovery lock.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + discovery_finished = None + first_discovery_started = asyncio.Event() + + async def mock_discovery(*args): + """Block to simulate multiple discovery calls while one still running.""" + nonlocal discovery_finished + if discovery_finished: + first_discovery_started.set() + await discovery_finished.wait() + return [] + + with patch( + "homeassistant.components.kulersky.light.pykulersky.discover_bluetooth_devices", + return_value=[], + ), patch( + "homeassistant.components.kulersky.light.async_track_time_interval", + ) as mock_track_time_interval: + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + with patch.object( + hass, "async_add_executor_job", side_effect=mock_discovery + ) as mock_run_discovery: + discovery_coroutine = mock_track_time_interval.call_args[0][1] + + discovery_finished = asyncio.Event() + + # Schedule multiple discoveries + hass.async_create_task(discovery_coroutine()) + hass.async_create_task(discovery_coroutine()) + hass.async_create_task(discovery_coroutine()) + + # Wait until the first discovery call is blocked + await first_discovery_started.wait() + + # Unblock the first discovery + discovery_finished.set() + + # Flush the remaining jobs + await hass.async_block_till_done() + + # The discovery method should only have been called once + mock_run_discovery.assert_called_once() + + +async def test_discovery_connection_error(hass, mock_entry): + """Test that invalid devices are skipped.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + light = MagicMock(spec=pykulersky.Light) + light.address = "AA:BB:CC:11:22:33" + light.name = "Bedroom" + light.connected = False + with patch( + "homeassistant.components.kulersky.light.pykulersky.discover_bluetooth_devices", + return_value=[ + { + "address": "AA:BB:CC:11:22:33", + "name": "Bedroom", + } + ], + ): + with patch( + "homeassistant.components.kulersky.light.pykulersky.Light" + ) as mockdevice, patch.object( + light, "connect", side_effect=pykulersky.PykulerskyException + ): + mockdevice.return_value = light + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Assert entity was not added + state = hass.states.get("light.bedroom") + assert state is None + + +async def test_remove_entry(hass, mock_light, mock_entry): + """Test platform setup.""" + with patch.object(mock_light, "disconnect") as mock_disconnect: + await hass.config_entries.async_remove(mock_entry.entry_id) + + assert mock_disconnect.called + + +async def test_update_exception(hass, mock_light): + """Test platform setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch.object( + mock_light, "get_color", side_effect=pykulersky.PykulerskyException + ): + await hass.helpers.entity_component.async_update_entity("light.bedroom") + state = hass.states.get("light.bedroom") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_light_turn_on(hass, mock_light): + """Test KulerSkyLight turn_on.""" + with patch.object(mock_light, "set_color") as mock_set_color, patch.object( + mock_light, "get_color", return_value=(255, 255, 255, 255) + ): + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: "light.bedroom"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_color.assert_called_with(255, 255, 255, 255) + + with patch.object(mock_light, "set_color") as mock_set_color, patch.object( + mock_light, "get_color", return_value=(50, 50, 50, 255) + ): + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: "light.bedroom", ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_color.assert_called_with(50, 50, 50, 255) + + with patch.object(mock_light, "set_color") as mock_set_color, patch.object( + mock_light, "get_color", return_value=(50, 45, 25, 255) + ): + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: "light.bedroom", ATTR_HS_COLOR: (50, 50)}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_set_color.assert_called_with(50, 45, 25, 255) + + with patch.object(mock_light, "set_color") as mock_set_color, patch.object( + mock_light, "get_color", return_value=(220, 201, 110, 180) + ): + await hass.services.async_call( + "light", + "turn_on", + {ATTR_ENTITY_ID: "light.bedroom", ATTR_WHITE_VALUE: 180}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_color.assert_called_with(50, 45, 25, 180) + + +async def test_light_turn_off(hass, mock_light): + """Test KulerSkyLight turn_on.""" + with patch.object(mock_light, "set_color") as mock_set_color, patch.object( + mock_light, "get_color", return_value=(0, 0, 0, 0) + ): + await hass.services.async_call( + "light", + "turn_off", + {ATTR_ENTITY_ID: "light.bedroom"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_color.assert_called_with(0, 0, 0, 0) + + +async def test_light_update(hass, mock_light): + """Test KulerSkyLight update.""" + utcnow = dt_util.utcnow() + + state = hass.states.get("light.bedroom") + assert state.state == STATE_OFF + assert state.attributes == { + ATTR_FRIENDLY_NAME: "Bedroom", + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS + | SUPPORT_COLOR + | SUPPORT_WHITE_VALUE, + } + + # Test an exception during discovery + with patch.object( + mock_light, "get_color", side_effect=pykulersky.PykulerskyException("TEST") + ): + utcnow = utcnow + SCAN_INTERVAL + async_fire_time_changed(hass, utcnow) + await hass.async_block_till_done() + + state = hass.states.get("light.bedroom") + assert state.state == STATE_UNAVAILABLE + assert state.attributes == { + ATTR_FRIENDLY_NAME: "Bedroom", + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS + | SUPPORT_COLOR + | SUPPORT_WHITE_VALUE, + } + + with patch.object( + mock_light, + "get_color", + return_value=(80, 160, 200, 240), + ): + utcnow = utcnow + SCAN_INTERVAL + async_fire_time_changed(hass, utcnow) + await hass.async_block_till_done() + + state = hass.states.get("light.bedroom") + assert state.state == STATE_ON + assert state.attributes == { + ATTR_FRIENDLY_NAME: "Bedroom", + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS + | SUPPORT_COLOR + | SUPPORT_WHITE_VALUE, + ATTR_BRIGHTNESS: 200, + ATTR_HS_COLOR: (200, 60), + ATTR_RGB_COLOR: (102, 203, 255), + ATTR_WHITE_VALUE: 240, + ATTR_XY_COLOR: (0.184, 0.261), + }